From 8aba8ee44af2b2fe453b48dccf2ac977d0825c31 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 13 Dec 2025 12:03:35 +0000 Subject: [PATCH 01/19] Extract const --- frontend/local-client-cli/src/cli.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 0f8262f7..48fd8954 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -35,6 +35,8 @@ const LOG_LEVEL_ORDER = { [LogLevel.ERROR]: 3 }; +const HEALTH_CHECK_INTERVAL_MS = 30 * 1000; + async function main(): Promise { const args = parseArgs(process.argv); const absolutePath = path.resolve(args.localPath); @@ -147,7 +149,7 @@ async function main(): Promise { void client.checkConnection().then((status) => { writeHealthStatus(healthFile, status); }); - }, 30 * 1000); // every 30 seconds + }, HEALTH_CHECK_INTERVAL_MS); const clearHealthInterval = (): void => { clearInterval(healthInterval); }; From 1b71f3e780461224c3f8e465bae8381e3e6bd986 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 10:55:46 +0000 Subject: [PATCH 02/19] Always kill server --- .github/workflows/e2e.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0e437cbd..196e02f3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,9 +47,16 @@ jobs: run: | cd sync-server cargo run config-e2e.yml --color never & + SERVER_PID=$! cd .. scripts/e2e.sh 8 + EXIT_CODE=$? + + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + + exit $EXIT_CODE - name: Cleanup if: always() From 299c3baea97e0f93a0c046c1c7a4da202e04eb36 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 10:55:54 +0000 Subject: [PATCH 03/19] Don't publish PRs --- .github/workflows/publish-plugin.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml index 9e74c60d..92dd199b 100644 --- a/.github/workflows/publish-plugin.yml +++ b/.github/workflows/publish-plugin.yml @@ -3,8 +3,6 @@ name: Publish Obsidian plugin on: push: tags: ["*"] - pull_request: - branches: ["main"] env: CARGO_TERM_COLOR: always From 580c993071df52eeefc7e5dadfefb3cfb90fd8ca Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 11:05:36 +0000 Subject: [PATCH 04/19] Reject pending locks on reset --- .../src/utils/data-structures/locks.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts index 8ad60429..3f676667 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -1,3 +1,4 @@ +import { SyncResetError } from "../../services/sync-reset-error"; import type { Logger } from "../../tracing/logger"; import { awaitAll } from "../await-all"; @@ -12,9 +13,9 @@ export class Locks { private readonly locked = new Set(); /** Queue of resolve functions waiting for each key */ - private readonly waiters = new Map unknown)[]>(); + private readonly waiters = new Map unknown, (err: unknown) => unknown])[]>(); - public constructor(private readonly logger?: Logger) {} + public constructor(private readonly logger?: Logger) { } /** * Executes a function while holding exclusive locks on one or more keys. @@ -67,6 +68,13 @@ export class Locks { } public reset(): void { + // Resolve all waiting promises before clearing to prevent deadlock + // Any operation waiting for a lock will be granted access immediately + for (const waiting of this.waiters.values()) { + for (const [_, reject] of waiting) { + reject(new SyncResetError()); + } + } this.locked.clear(); this.waiters.clear(); } @@ -102,7 +110,7 @@ export class Locks { this.logger?.debug(`Waiting for lock on ${key}`); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { // DefaultDict behavior let waiting = this.waiters.get(key); if (!waiting) { @@ -110,7 +118,7 @@ export class Locks { this.waiters.set(key, waiting); } - waiting.push(resolve); + waiting.push([resolve, reject]); }); } @@ -127,11 +135,11 @@ export class Locks { } // Remove first waiter to ensure FIFO order - const nextWaiting = this.waiters.get(key)?.shift(); + const [resolveNextWaiting, _] = this.waiters.get(key)?.shift() ?? []; - if (nextWaiting) { + if (resolveNextWaiting) { this.logger?.debug(`Granted lock on ${key}`); - nextWaiting(); + resolveNextWaiting(); } else { this.locked.delete(key); } From b6ab01d56a47da4c749cb1b3a50858774f0da917 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 11:05:55 +0000 Subject: [PATCH 05/19] Handle websocket race condition --- frontend/sync-client/src/services/websocket-manager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index f8dc59d4..0cc4d15e 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -188,6 +188,11 @@ export class WebSocketManager { this.webSocket = new this.webSocketFactoryImplementation(wsUri); this.webSocket.onopen = (): void => { + // Check if we've been stopped while connecting + if (this.isStopped) { + this.webSocket?.close(1000, "WebSocketManager was stopped during connection"); + return; + } this.logger.info("WebSocket connection opened"); this.onWebSocketStatusChanged.trigger(true); }; From 47f24e168b8471d56a13982e40ca7e0bba4430eb Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 11:06:49 +0000 Subject: [PATCH 06/19] Wait for idle instead --- frontend/sync-client/src/sync-operations/syncer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 78cef699..709b9f62 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -171,7 +171,7 @@ export class Syncer { // in that case, we mustn't move it again. if ( this.database.getLatestDocumentByRelativePath(relativePath) === - undefined || + undefined || this.database.getLatestDocumentByRelativePath(relativePath) ?.isDeleted === true ) { @@ -264,7 +264,7 @@ export class Syncer { public async waitUntilFinished(): Promise { await this.runningScheduleSyncForOfflineChanges; - await this.syncQueue.onEmpty(); + await this.syncQueue.onIdle(); // Wait for queue to be empty and running tasks to finish } public async syncRemotelyUpdatedFile( From 7daa3637235e6335ce60da5bd03e9bd7552a6fe1 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 11:31:48 +0000 Subject: [PATCH 07/19] Unsubscribe in SyncClient --- frontend/sync-client/src/sync-client.ts | 43 ++++++++++++++++++------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 1544a1e0..633d20b5 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -34,6 +34,8 @@ export class SyncClient { private hasStarted = false; private hasBeenDestroyed = false; private unloadTelemetry?: () => void; + private isDestroying = false; + private readonly eventUnsubscribers: (() => void)[] = []; private constructor( private readonly history: SyncHistory, @@ -53,8 +55,9 @@ export class SyncClient { settings: Partial; database: Partial; }> - > - ) {} + >, + ) { + } public get documentCount(): number { return this.database.length; @@ -159,11 +162,6 @@ export class SyncClient { settings.getSettings().isSyncEnabled, logger ); - settings.onSettingsChanged.add((newSettings, oldSettings) => { - if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) { - fetchController.canFetch = newSettings.isSyncEnabled; - } - }); const syncService = new SyncService( deviceId, @@ -258,13 +256,23 @@ export class SyncClient { this.unloadTelemetry = setUpTelemetry(); } - this.logger.onLogEmitted.add((log): void => { - if (log.level === LogLevel.ERROR && Sentry.isInitialized()) { - Sentry.captureMessage(log.message); + this.eventUnsubscribers.push(this.settings.onSettingsChanged.add((newSettings, oldSettings) => { + if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) { + this.fetchController.canFetch = newSettings.isSyncEnabled; } - }); + })); - this.settings.onSettingsChanged.add(this.onSettingsChange.bind(this)); + this.eventUnsubscribers.push( + this.logger.onLogEmitted.add((log): void => { + if (log.level === LogLevel.ERROR && Sentry.isInitialized()) { + Sentry.captureMessage(log.message); + } + }) + ); + + this.eventUnsubscribers.push( + this.settings.onSettingsChanged.add(this.onSettingsChange.bind(this)) + ); if (this.settings.getSettings().isSyncEnabled) { this.logger.info("Starting SyncClient"); @@ -431,6 +439,13 @@ export class SyncClient { public async destroy(): Promise { this.checkIfDestroyed("destroy"); + // Prevent concurrent destroy calls + if (this.isDestroying) { + this.logger.warn("destroy() called while already destroying, ignoring"); + return; + } + this.isDestroying = true; + // cancel everything that's in progress await this.pause(); @@ -438,6 +453,10 @@ export class SyncClient { this.resetInMemoryState(); + // Clean up event listeners to prevent memory leaks + this.eventUnsubscribers.forEach((unsubscribe) => unsubscribe()); + this.eventUnsubscribers.length = 0; + this.logger.info("SyncClient has been successfully disposed"); this.unloadTelemetry?.(); From 4fb3839b3ead9c782ef10d96d1f62a85f04c7f17 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 11:43:57 +0000 Subject: [PATCH 08/19] Add lock tests --- .../src/utils/data-structures/locks.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/frontend/sync-client/src/utils/data-structures/locks.test.ts b/frontend/sync-client/src/utils/data-structures/locks.test.ts index 0c09c062..c1a4fb4b 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.test.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.test.ts @@ -5,6 +5,7 @@ import type { RelativePath } from "../../persistence/database"; import { Locks } from "./locks"; import { awaitAll } from "../await-all"; import { sleep } from "../sleep"; +import { SyncResetError } from "../../services/sync-reset-error"; describe("withLock", () => { const testPath: RelativePath = "test/document/path"; @@ -230,3 +231,62 @@ describe("withLock", () => { ]); }); }); + +describe("reset", () => { + const testPath: RelativePath = "test/document/path"; + const logger = new Logger(); + + // eslint-disable-next-line @typescript-eslint/init-declarations + let locks: Locks; + + beforeEach(() => { + locks = new Locks(logger); + }); + + it("should reject pending waiters with SyncResetError while running operation completes", async () => { + const firstPromise = locks.withLock(testPath, async () => { + await sleep(2); + return "first"; + }); + + await sleep(1); + + const secondPromise = locks.withLock(testPath, async () => "second"); + void secondPromise.catch(() => { }); + + locks.reset(); + + assert.strictEqual(await firstPromise, "first"); + + await assert.rejects(secondPromise, (err: Error) => { + assert.ok(err instanceof SyncResetError); + return true; + }); + }); + + it("should allow locks to work normally after reset", async () => { + const firstPromise = locks.withLock(testPath, async () => { + await sleep(1); + return "first"; + }); + + await sleep(1); + + const secondPromise = locks.withLock(testPath, async () => "second"); + void secondPromise.catch(() => { }); + + locks.reset(); + + await firstPromise; + + const result = await locks.withLock(testPath, () => "after-reset"); + assert.strictEqual(result, "after-reset"); + }); + + it("should handle reset with no pending operations", async () => { + locks.reset(); + + const result = await locks.withLock(testPath, () => "success"); + assert.strictEqual(result, "success"); + }); +}); From 42a77a5cd52786552d5571c64f879e8289955157 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 11:47:47 +0000 Subject: [PATCH 09/19] Upload logs instead of printing them --- .github/workflows/e2e.yml | 8 ++++++++ scripts/e2e.sh | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 196e02f3..19a44428 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -58,6 +58,14 @@ jobs: exit $EXIT_CODE + - name: Upload e2e logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs + path: logs/ + retention-days: 30 + - name: Cleanup if: always() run: scripts/clean-up.sh diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 49f320a0..77a3d19c 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -51,7 +51,7 @@ for i in $(seq 1 $process_count); do echo "Started process $i with PID: $pid" # Read from pipe, prefix with PID - (sed "s/^/[PID $pid] /" < "$pipe" | tee "../logs/log_${i}.log"; rm "$pipe") & + (sed "s/^/[PID $pid] /" < "$pipe" > "../logs/log_${i}.log"; rm "$pipe") & done cd .. From 0e0a85df82cd5bb3295c34a46ff0b3656770067a Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 13:53:35 +0000 Subject: [PATCH 10/19] Check node version --- .github/workflows/check.yml | 8 +------- scripts/build-docs.sh | 2 ++ scripts/check.sh | 7 +++++++ scripts/e2e.sh | 6 +----- scripts/utils/check-node.sh | 9 +++++++++ 5 files changed, 20 insertions(+), 12 deletions(-) create mode 100755 scripts/utils/check-node.sh diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index cf890830..9aa71fb4 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -5,6 +5,7 @@ on: branches: ["main"] pull_request: branches: ["main"] + workflow_dispatch: env: CARGO_TERM_COLOR: always @@ -31,12 +32,5 @@ jobs: toolchain: "1.89.0" components: clippy, rustfmt - - name: Setup rust - run: | - which sqlx || cargo install sqlx-cli - cd sync-server - sqlx database create --database-url sqlite://db.sqlite3 - sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 - - name: Lint & test run: scripts/check.sh diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 9f3c76d4..c87144a9 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -2,6 +2,8 @@ set -e +./scripts/utils/check-node.sh + cd docs npm ci diff --git a/scripts/check.sh b/scripts/check.sh index 2a13953a..bac8f3c3 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -8,8 +8,15 @@ if [[ "$1" == "--fix" ]]; then echo "Running in fix mode - will automatically fix linting and formatting issues" fi +./scripts/utils/check-node.sh + echo "Running checks in sync-server" + cd sync-server +which sqlx || cargo install sqlx-cli +sqlx database create --database-url sqlite://db.sqlite3 +sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 + cargo test --verbose if [[ "$FIX_MODE" == true ]]; then diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 77a3d19c..6c66e835 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -6,11 +6,7 @@ set -o pipefail NO_COLOR=1 FORCE_COLOR=0 -node_version=$(node -v | sed 's/^v\([0-9]*\).*/\1/') -if [ "$node_version" != "22" ]; then - echo "Error: This script requires Node.js version 22, found: $node_version" - exit 1 -fi +./scripts/utils/check-node.sh # Check if the argument is provided if [ $# -eq 0 ]; then diff --git a/scripts/utils/check-node.sh b/scripts/utils/check-node.sh new file mode 100755 index 00000000..c9ede47e --- /dev/null +++ b/scripts/utils/check-node.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e + +node_version=$(node -v | sed 's/^v\([0-9]*\).*/\1/') +if [ "$node_version" != "22" ]; then + echo "Error: This script requires Node.js version 22, found: $node_version" + exit 1 +fi From 5efe30d9d650b07240cdfb26feb04d085277d8ea Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 13:55:23 +0000 Subject: [PATCH 11/19] Format & lint --- .github/workflows/e2e.yml | 1 + .../src/services/websocket-manager.ts | 5 +++- frontend/sync-client/src/sync-client.ts | 29 ++++++++++++------- .../sync-client/src/sync-operations/syncer.ts | 2 +- .../src/utils/data-structures/locks.test.ts | 4 +-- .../src/utils/data-structures/locks.ts | 7 +++-- scripts/check.sh | 6 +--- 7 files changed, 32 insertions(+), 22 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 19a44428..7d0a2a0f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -7,6 +7,7 @@ on: branches: ["main"] schedule: - cron: '0 * * * *' + workflow_dispatch: concurrency: group: e2e-tests diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 0cc4d15e..09787bce 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -190,7 +190,10 @@ export class WebSocketManager { this.webSocket.onopen = (): void => { // Check if we've been stopped while connecting if (this.isStopped) { - this.webSocket?.close(1000, "WebSocketManager was stopped during connection"); + this.webSocket?.close( + 1000, + "WebSocketManager was stopped during connection" + ); return; } this.logger.info("WebSocket connection opened"); diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 633d20b5..2a272c86 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -55,9 +55,8 @@ export class SyncClient { settings: Partial; database: Partial; }> - >, - ) { - } + > + ) {} public get documentCount(): number { return this.database.length; @@ -256,11 +255,13 @@ export class SyncClient { this.unloadTelemetry = setUpTelemetry(); } - this.eventUnsubscribers.push(this.settings.onSettingsChanged.add((newSettings, oldSettings) => { - if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) { - this.fetchController.canFetch = newSettings.isSyncEnabled; - } - })); + this.eventUnsubscribers.push( + this.settings.onSettingsChanged.add((newSettings, oldSettings) => { + if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) { + this.fetchController.canFetch = newSettings.isSyncEnabled; + } + }) + ); this.eventUnsubscribers.push( this.logger.onLogEmitted.add((log): void => { @@ -271,7 +272,9 @@ export class SyncClient { ); this.eventUnsubscribers.push( - this.settings.onSettingsChanged.add(this.onSettingsChange.bind(this)) + this.settings.onSettingsChanged.add( + this.onSettingsChange.bind(this) + ) ); if (this.settings.getSettings().isSyncEnabled) { @@ -441,7 +444,9 @@ export class SyncClient { // Prevent concurrent destroy calls if (this.isDestroying) { - this.logger.warn("destroy() called while already destroying, ignoring"); + this.logger.warn( + "destroy() called while already destroying, ignoring" + ); return; } this.isDestroying = true; @@ -454,7 +459,9 @@ export class SyncClient { this.resetInMemoryState(); // Clean up event listeners to prevent memory leaks - this.eventUnsubscribers.forEach((unsubscribe) => unsubscribe()); + this.eventUnsubscribers.forEach((unsubscribe) => { + unsubscribe(); + }); this.eventUnsubscribers.length = 0; this.logger.info("SyncClient has been successfully disposed"); diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 709b9f62..71dedd85 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -171,7 +171,7 @@ export class Syncer { // in that case, we mustn't move it again. if ( this.database.getLatestDocumentByRelativePath(relativePath) === - undefined || + undefined || this.database.getLatestDocumentByRelativePath(relativePath) ?.isDeleted === true ) { diff --git a/frontend/sync-client/src/utils/data-structures/locks.test.ts b/frontend/sync-client/src/utils/data-structures/locks.test.ts index c1a4fb4b..9beb867a 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.test.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.test.ts @@ -252,7 +252,7 @@ describe("reset", () => { await sleep(1); const secondPromise = locks.withLock(testPath, async () => "second"); - void secondPromise.catch(() => { }); + void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function locks.reset(); @@ -273,7 +273,7 @@ describe("reset", () => { await sleep(1); const secondPromise = locks.withLock(testPath, async () => "second"); - void secondPromise.catch(() => { }); + void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function locks.reset(); diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts index 3f676667..e55c76b0 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -13,9 +13,12 @@ export class Locks { private readonly locked = new Set(); /** Queue of resolve functions waiting for each key */ - private readonly waiters = new Map unknown, (err: unknown) => unknown])[]>(); + private readonly waiters = new Map< + T, + [() => unknown, (err: unknown) => unknown][] + >(); - public constructor(private readonly logger?: Logger) { } + public constructor(private readonly logger?: Logger) {} /** * Executes a function while holding exclusive locks on one or more keys. diff --git a/scripts/check.sh b/scripts/check.sh index bac8f3c3..7c3c87e5 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -58,8 +58,4 @@ fi cd .. -if [[ "$FIX_MODE" == true ]]; then - $0 -else - echo "Success" -fi +echo "Success" From 9a75569e834ceac8ffecb1421e907f1f05f39b4e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 14 Dec 2025 23:31:40 +0000 Subject: [PATCH 12/19] Bump versions to 0.14.0 --- frontend/local-client-cli/package.json | 2 +- frontend/obsidian-plugin/manifest.json | 2 +- frontend/obsidian-plugin/package.json | 2 +- frontend/package-lock.json | 63 +++++++++----------------- frontend/sync-client/package.json | 2 +- frontend/test-client/package.json | 2 +- manifest.json | 2 +- sync-server/Cargo.lock | 2 +- sync-server/Cargo.toml | 2 +- 9 files changed, 30 insertions(+), 49 deletions(-) diff --git a/frontend/local-client-cli/package.json b/frontend/local-client-cli/package.json index 6cfa180c..cade4990 100644 --- a/frontend/local-client-cli/package.json +++ b/frontend/local-client-cli/package.json @@ -1,6 +1,6 @@ { "name": "local-client-cli", - "version": "0.13.1", + "version": "0.14.0", "description": "Standalone CLI for VaultLink sync client", "private": false, "bin": { diff --git a/frontend/obsidian-plugin/manifest.json b/frontend/obsidian-plugin/manifest.json index 355c2ddc..6f72fab0 100644 --- a/frontend/obsidian-plugin/manifest.json +++ b/frontend/obsidian-plugin/manifest.json @@ -1,7 +1,7 @@ { "id": "vault-link", "name": "VaultLink", - "version": "0.13.1", + "version": "0.14.0", "minAppVersion": "0.0.0", "description": "Self-hosted synchronization and collaboration for your Vault.", "author": "Andras Schmelczer", diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json index 219fca41..b7ae4909 100644 --- a/frontend/obsidian-plugin/package.json +++ b/frontend/obsidian-plugin/package.json @@ -1,6 +1,6 @@ { "name": "vault-link-obsidian-plugin", - "version": "0.13.1", + "version": "0.14.0", "description": "This is a sample plugin for Obsidian (https://obsidian.md)", "main": "main.js", "scripts": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3e944c5c..4d8218ba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,7 +22,7 @@ } }, "local-client-cli": { - "version": "0.13.1", + "version": "0.14.0", "dependencies": { "commander": "^14.0.2", "watcher": "^2.3.1" @@ -759,8 +759,7 @@ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -1014,6 +1013,7 @@ "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.41.0", @@ -1052,6 +1052,7 @@ "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.41.0", "@typescript-eslint/types": "8.41.0", @@ -1452,6 +1453,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1483,6 +1485,7 @@ "version": "6.12.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1818,6 +1821,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -1909,20 +1913,6 @@ "node": ">=6.9.5" } }, - "node_modules/bufferutil": { - "version": "4.0.9", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/byte-base64": { "version": "1.1.0", "dev": true, @@ -2296,8 +2286,7 @@ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -3021,6 +3010,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4899,6 +4889,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5052,7 +5043,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -5541,6 +5531,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -6053,6 +6044,7 @@ "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -6430,8 +6422,7 @@ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/supports-color": { "version": "8.1.1", @@ -6614,6 +6605,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6869,6 +6861,7 @@ "version": "5.8.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6991,20 +6984,6 @@ "dev": true, "license": "MIT" }, - "node_modules/utf-8-validate": { - "version": "6.0.5", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "dev": true, @@ -7109,8 +7088,7 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/watcher": { "version": "2.3.1", @@ -7138,6 +7116,7 @@ "version": "5.99.9", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -7184,6 +7163,7 @@ "version": "6.0.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -7259,6 +7239,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7483,7 +7464,7 @@ }, "obsidian-plugin": { "name": "vault-link-obsidian-plugin", - "version": "0.13.1", + "version": "0.14.0", "license": "MIT", "devDependencies": { "@types/node": "^24.8.1", @@ -7509,7 +7490,7 @@ } }, "sync-client": { - "version": "0.13.1", + "version": "0.14.0", "devDependencies": { "@sentry/browser": "^10.8.0", "@types/node": "^24.8.1", @@ -7553,7 +7534,7 @@ } }, "test-client": { - "version": "0.13.1", + "version": "0.14.0", "bin": { "test-client": "dist/cli.js" }, diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index 1ae9b8f0..aa369fa7 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -1,6 +1,6 @@ { "name": "sync-client", - "version": "0.13.1", + "version": "0.14.0", "main": "dist/sync-client.node.js", "browser": "dist/sync-client.web.js", "types": "dist/types/index.d.ts", diff --git a/frontend/test-client/package.json b/frontend/test-client/package.json index ca4c1479..3d0d0c1a 100644 --- a/frontend/test-client/package.json +++ b/frontend/test-client/package.json @@ -1,6 +1,6 @@ { "name": "test-client", - "version": "0.13.1", + "version": "0.14.0", "private": true, "bin": { "test-client": "./dist/cli.js" diff --git a/manifest.json b/manifest.json index 355c2ddc..6f72fab0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "vault-link", "name": "VaultLink", - "version": "0.13.1", + "version": "0.14.0", "minAppVersion": "0.0.0", "description": "Self-hosted synchronization and collaboration for your Vault.", "author": "Andras Schmelczer", diff --git a/sync-server/Cargo.lock b/sync-server/Cargo.lock index 906b232b..b3da1486 100644 --- a/sync-server/Cargo.lock +++ b/sync-server/Cargo.lock @@ -2123,7 +2123,7 @@ dependencies = [ [[package]] name = "sync_server" -version = "0.13.1" +version = "0.14.0" dependencies = [ "anyhow", "axum", diff --git a/sync-server/Cargo.toml b/sync-server/Cargo.toml index c60a65a2..fac06efa 100644 --- a/sync-server/Cargo.toml +++ b/sync-server/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Andras Schmelczer "] edition = "2024" license = "MIT" repository = "https://github.com/schmelczer/vault-link" -version = "0.13.1" +version = "0.14.0" [dependencies] serde = { version = "1.0.219", default-features = false, features = ["derive"] } From 4482e0155f73e3b53f93ccc3377f8402b6142c17 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 8 May 2026 21:53:33 +0100 Subject: [PATCH 13/19] Migrate to forgejo & reformat (#189) - Migrate to forgejo - Bump Rust & Node - Reformat project - Small script cleanup Reviewed-on: https://home.schmelczer.dev/git/git/andras/vault-link/pulls/189 Co-authored-by: Andras Schmelczer Co-committed-by: Andras Schmelczer --- .forgejo/workflows/check.yml | 35 + .forgejo/workflows/deploy-docs.yml | 38 + .forgejo/workflows/e2e.yml | 71 + .forgejo/workflows/publish-cli-docker.yml | 51 + .forgejo/workflows/publish-plugin.yml | 71 + .forgejo/workflows/publish-server-docker.yml | 51 + .github/workflows/check.yml | 4 +- .github/workflows/deploy-docs.yml | 13 +- .github/workflows/e2e.yml | 6 +- .github/workflows/publish-plugin.yml | 4 +- .gitignore | 9 +- .vscode/settings.json | 4 +- CLAUDE.md | 195 +- README.md | 8 +- docs/.cspell.json | 7 +- docs/architecture/data-flow.md | 58 +- docs/architecture/index.md | 2 +- docs/config/authentication.md | 6 +- docs/guide/server-setup.md | 2 +- docs/package-lock.json | 5960 +++++++++--------- package-lock.json | 6 + rustfmt.toml | 11 + scripts/bump-version.sh | 3 +- scripts/check.sh | 14 +- scripts/clean-up.sh | 2 +- scripts/e2e.sh | 72 +- scripts/update-api-types.sh | 10 +- scripts/utils/check-node.sh | 6 +- scripts/utils/wait-for-server.sh | 4 +- 29 files changed, 3571 insertions(+), 3152 deletions(-) create mode 100644 .forgejo/workflows/check.yml create mode 100644 .forgejo/workflows/deploy-docs.yml create mode 100644 .forgejo/workflows/e2e.yml create mode 100644 .forgejo/workflows/publish-cli-docker.yml create mode 100644 .forgejo/workflows/publish-plugin.yml create mode 100644 .forgejo/workflows/publish-server-docker.yml create mode 100644 package-lock.json create mode 100644 rustfmt.toml diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 00000000..40e01dea --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,35 @@ +name: Check + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: "25.x" + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92.0" + components: clippy, rustfmt + + - name: Lint & test + run: scripts/check.sh diff --git a/.forgejo/workflows/deploy-docs.yml b/.forgejo/workflows/deploy-docs.yml new file mode 100644 index 00000000..c49d0379 --- /dev/null +++ b/.forgejo/workflows/deploy-docs.yml @@ -0,0 +1,38 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - "docs/**" + - ".forgejo/workflows/deploy-docs.yml" + workflow_dispatch: + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: "25.x" + + - name: Build docs + run: scripts/build-docs.sh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs/.vitepress/dist diff --git a/.forgejo/workflows/e2e.yml b/.forgejo/workflows/e2e.yml new file mode 100644 index 00000000..eb8d1e54 --- /dev/null +++ b/.forgejo/workflows/e2e.yml @@ -0,0 +1,71 @@ +name: E2E tests + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "0 * * * *" + workflow_dispatch: + +concurrency: + group: e2e-tests + cancel-in-progress: false + +env: + RUSTFLAGS: "-Dwarnings" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: "25.x" + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92.0" + components: clippy, rustfmt + + - name: Setup rust + run: | + which sqlx || cargo install sqlx-cli + cd sync-server + sqlx database create --database-url sqlite://db.sqlite3 + sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 + + - name: E2E tests + run: | + cd sync-server + cargo run config-e2e.yml --color never & + SERVER_PID=$! + cd .. + + scripts/e2e.sh 8 + EXIT_CODE=$? + + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + + exit $EXIT_CODE + + - name: Upload e2e logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs + path: logs/ + retention-days: 30 + + - name: Cleanup + if: always() + run: scripts/clean-up.sh diff --git a/.forgejo/workflows/publish-cli-docker.yml b/.forgejo/workflows/publish-cli-docker.yml new file mode 100644 index 00000000..265283ab --- /dev/null +++ b/.forgejo/workflows/publish-cli-docker.yml @@ -0,0 +1,51 @@ +name: Publish CLI + +on: + push: + branches: ["main"] + tags: ["*"] + pull_request: + branches: ["main"] + +jobs: + publish-docker: + runs-on: ubuntu-docker + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract registry hostname + id: registry + run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into container registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.registry.outputs.host }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.registry.outputs.host }}/${{ github.repository }}-cli + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: frontend + file: frontend/local-client-cli/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache + cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache,mode=max diff --git a/.forgejo/workflows/publish-plugin.yml b/.forgejo/workflows/publish-plugin.yml new file mode 100644 index 00000000..25a652aa --- /dev/null +++ b/.forgejo/workflows/publish-plugin.yml @@ -0,0 +1,71 @@ +name: Publish Obsidian plugin + +on: + push: + tags: ["*"] + +env: + CARGO_TERM_COLOR: always + +jobs: + publish-plugin: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: "25.x" + + - name: Build plugin + run: | + cd frontend + npm ci + npm run build + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92.0" + components: clippy, rustfmt + + - name: Install cross-compilation tools + run: | + apt update + apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 jq + + - name: Build Linux and Windows binaries + run: ./scripts/build-sync-server-binaries.sh + + - name: Create release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + run: | + tag="${GITHUB_REF#refs/tags/}" + + mkdir -p release + cp frontend/obsidian-plugin/dist/* release/ + cp sync-server/artifacts/sync-server-* release/ + + # Create draft release via Forgejo API + RELEASE_ID=$(curl -s -X POST \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"${tag}\", \"name\": \"${tag}\", \"draft\": true}" \ + | jq -r '.id') + + # Upload release assets + for file in release/*; do + filename=$(basename "$file") + curl -s -X POST \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -F "attachment=@${file}" + done diff --git a/.forgejo/workflows/publish-server-docker.yml b/.forgejo/workflows/publish-server-docker.yml new file mode 100644 index 00000000..23852e56 --- /dev/null +++ b/.forgejo/workflows/publish-server-docker.yml @@ -0,0 +1,51 @@ +name: Publish server Docker image + +on: + push: + branches: ["main"] + tags: ["*"] + pull_request: + branches: ["main"] + +jobs: + publish-docker: + runs-on: ubuntu-docker + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract registry hostname + id: registry + run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into container registry + if: github.ref_type == 'tag' + uses: docker/login-action@v3 + with: + registry: ${{ steps.registry.outputs.host }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.registry.outputs.host }}/${{ github.repository }} + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: sync-server + platforms: linux/amd64,linux/arm64 + push: ${{ github.ref_type == 'tag' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache + cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache,mode=max diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9aa71fb4..fc1b1c99 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,13 +23,13 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v4.2.0 with: - node-version: "22.x" + node-version: "25.x" check-latest: true - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: "1.89.0" + toolchain: "1.92.0" components: clippy, rustfmt - name: Lint & test diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index b6d369cc..bb25e463 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -5,8 +5,8 @@ on: branches: - main paths: - - 'docs/**' - - '.github/workflows/deploy-docs.yml' + - "docs/**" + - ".github/workflows/deploy-docs.yml" workflow_dispatch: permissions: @@ -28,12 +28,11 @@ jobs: with: fetch-depth: 0 - - name: Setup Node - uses: actions/setup-node@v4 + - name: Setup Node.js environment + uses: actions/setup-node@v4.2.0 with: - node-version: 22 - cache: npm - cache-dependency-path: docs/package-lock.json + node-version: "25.x" + check-latest: true - name: Setup Pages uses: actions/configure-pages@v4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7d0a2a0f..98dbfc1f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -6,7 +6,7 @@ on: pull_request: branches: ["main"] schedule: - - cron: '0 * * * *' + - cron: "0 * * * *" workflow_dispatch: concurrency: @@ -28,13 +28,13 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v4.2.0 with: - node-version: "22.x" + node-version: "25.x" check-latest: true - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: "1.89.0" + toolchain: "1.92.0" components: clippy, rustfmt - name: Setup rust diff --git a/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml index 92dd199b..452bc601 100644 --- a/.github/workflows/publish-plugin.yml +++ b/.github/workflows/publish-plugin.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v4.2.0 with: - node-version: "22.x" + node-version: "25.x" check-latest: true - name: Build plugin @@ -31,7 +31,7 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: "1.89.0" + toolchain: "1.92.0" components: clippy, rustfmt - name: Install cross-compilation tools diff --git a/.gitignore b/.gitignore index a1c1ac4f..967b2b65 100644 --- a/.gitignore +++ b/.gitignore @@ -7,15 +7,18 @@ node_modules # Frontend build folders frontend/*/dist -sync-server/db.sqlite3* -sync-server/databases - # Rust build folders sync-server/target sync-server/artifacts sync-server/bindings/*.ts +# build folders +sync-server/db.sqlite3* +**/databases + *.log *.sqlx target + +.task diff --git a/.vscode/settings.json b/.vscode/settings.json index 88d395f5..98187650 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,6 @@ "**/dist": true, "**/node_modules": true, "**/.sqlx": true, - "**/target": true, - }, + "**/target": true + } } diff --git a/CLAUDE.md b/CLAUDE.md index c77b091b..39161e39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,109 +2,154 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Project Overview +## Project shape -VaultLink is a self-hosted Obsidian plugin for real-time collaborative file syncing. The project consists of a Rust-based sync server and a TypeScript frontend with three main components: an Obsidian plugin, a sync client library, and a test client. +VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo: -## Architecture +- `sync-server/` — Rust (axum + sqlx/SQLite). Source of truth for vault state, broadcasts changes via WebSocket. +- `frontend/` — npm workspaces. The sync engine (`sync-client`) is consumed by an Obsidian plugin, a standalone CLI, a fuzz E2E harness, a scripted determinism harness, and a history UI. -### Core Components +The HTTP/WS API types are generated from Rust (`ts-rs`) and mirrored into the TS workspaces. **Never hand-edit files in `frontend/sync-client/src/services/types/` or `frontend/history-ui/src/lib/types/`** — run `scripts/update-api-types.sh` after changing anything Serde-derived in the server. -- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization -- **frontend/sync-client/**: TypeScript library providing core sync functionality, WebSocket management, and file operations -- **frontend/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API -- **frontend/test-client/**: CLI testing tool for the sync functionality +### Frontend workspaces -### Key Technologies +- `sync-client` — the sync engine; published to consumers via `dist/`. All other TS workspaces depend on it via `file:../sync-client`. +- `obsidian-plugin` — Obsidian plugin built from `sync-client`. +- `local-client-cli` — same engine wrapped as a standalone CLI. +- `history-ui` — vault-history web UI. +- `test-client` — fuzz E2E harness (random ops across N processes). +- `deterministic-tests` — scripted multi-client tests with an in-memory FS, run against a real server. -- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync -- **Frontend**: TypeScript, Webpack for bundling, Jest for testing -- **Sync Algorithm**: Uses reconcile-text library for operational transformation +## Common commands -## Development Commands +Pre-push hygiene (formats, lints, runs tests, requires clean git state): -### Server Development -```bash -cd sync-server -cargo run config-e2e.yml # Start development server -cargo test --verbose # Run Rust tests -cargo clippy --all-targets --all-features # Lint Rust code -cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged # Auto-fix clippy warnings -cargo fmt --all -- --check # Check Rust formatting -cargo fmt --all # Auto-format Rust code -cargo machete --with-metadata # Detect unused dependencies +```sh +scripts/check.sh --fix ``` -### Frontend Development -```bash +Run the fuzz E2E (N parallel processes): + +```sh +scripts/e2e.sh 12 +# Logs land in logs/log_.log. Clean with scripts/clean-up.sh +``` + +Run deterministic tests (require a release-built server in `sync-server/target/release/sync_server` — they spawn it themselves): + +```sh +cd sync-server && cargo build --release && cd .. cd frontend -npm run dev # Start development mode (watches sync-client and obsidian-plugin) -npm run build # Build all workspaces -npm run test # Run all tests -npm run lint # Lint and format TypeScript code +npm run build -w sync-client -w deterministic-tests +node deterministic-tests/dist/cli.js # all +node deterministic-tests/dist/cli.js --filter=rename # subset +node deterministic-tests/dist/cli.js --filter=… -j 4 # cap parallelism ``` -### Database Setup (Development) -```bash +Run a single sync-client unit test by file: + +```sh +cd frontend/sync-client && npx tsx --test 'src/**/sync-event-queue.test.ts' +``` + +Server: dev runs from `sync-server/` against `config-e2e.yml`: + +```sh +cd sync-server +cargo run config-e2e.yml # dev +cargo build --release # used by both e2e harnesses +cargo test # unit + ts-rs binding export tests +``` + +Frontend dev (sync-client + obsidian-plugin watch in parallel): + +```sh +cd frontend && npm install && npm run dev +``` + +Regenerate TS bindings from Rust types (touches `frontend/{sync-client,history-ui}/src/.../types/`): + +```sh +scripts/update-api-types.sh +``` + +## SQLite / sqlx + +The server uses `sqlx::query!` macros that need a prepared `.sqlx` cache to compile offline. Touching any SQL means regenerating it: + +```sh cd sync-server sqlx database create --database-url sqlite://db.sqlite3 sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 cargo sqlx prepare --workspace ``` -### Initial Setup -```bash -# Install required cargo tools -cargo install sqlx-cli cargo-machete cargo-edit +New migrations: `sqlx migrate add --source src/app_state/database/migrations `. + +## Sync engine architecture + +Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry). + +The engine is **two independent loops with separate invariants**: + +- **Wire loop** (`syncer.ts`) — drains the single-consumer FIFO queue. HTTP and WS handlers update record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and write content to the file at `record.localPath`. They never move files for path placement. +- **Path reconciler** (`reconciler.ts`) — runs after every drained event. Best-effort pass that moves files to make `localPath === remoteRelativePath`. The move graph is topologically sorted; cycles are resolved by reading every file in the cycle into memory and writing each back to its new slot (no tmp files). Records with pending local events are skipped on each pass — the reconciler operates only on settled records. Failures (slot occupied by an untracked file, etc.) are silent skips; the next pass retries. + +**`SyncEventQueue`** (`sync-event-queue.ts`) holds: + +- `byDocId: Map` — primary record store. +- `byLocalPath: Map` — derived index for path lookups, maintained at every mutation point. +- `events: SyncEvent[]` — pending wire ops in FIFO drain order. + +```ts +DocumentRecord = { + documentId, + parentVersionId, + remoteHash?, + remoteRelativePath, + localPath: RelativePath | undefined +} ``` -### Scripts -- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend) -- `scripts/check.sh --fix`: Same as above but auto-fixes linting and formatting issues -- `scripts/e2e.sh`: End-to-end testing -- `scripts/clean-up.sh`: Clean logs and database files -- `scripts/bump-version.sh patch`: Publish new version -- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types +`localPath === undefined` means the doc has no local file yet — typically a remote create whose target slot was occupied at receive time; the reconciler will fetch and place when the slot frees (the bytes wait in `pendingPlacementContent`). -## Code Structure +Local FS events from the watcher update `localPath` synchronously at enqueue time via `setLocalPath` / `upsertRecord`. The wire loop never updates it for path placement; only the reconciler does. A user rename onto a tracked slot enqueues a `LocalDelete` for the displaced doc (the OS rename clobbered its content) and clears that doc's `localPath`. -### Workspace Configuration -The frontend uses npm workspaces with four packages: -- `sync-client`: Core synchronization logic -- `obsidian-plugin`: Obsidian-specific integration -- `test-client`: Testing utilities -- `local-client-cli`: Standalone CLI for VaultLink sync client +**Pending creates** use a `Promise` chain to serialize dependent ops (`LocalUpdate`, `LocalDelete`) behind the still-in-flight `LocalCreate`. `resolveCreate` resolves the promise once the server returns a docId, and `replacePendingDocumentId` swaps the resolved id across already-queued events. `findLatestCreateForPath` is the lookup the watcher uses to attach dependents; `updatePendingCreatePath` rewrites a pending create's `event.path` in place when the user renames the file before its create has acked. -### Type Generation -Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages. +**Watermark.** `lastSeenUpdateId` uses a `MinCovered` (a contiguous-prefix tracker over a stream of integers): we only advance the published min when the next consecutive id has been processed, so out-of-order RemoteChange ids don't fool the WebSocket handshake into requesting a too-recent catch-up. -### Key Files -- `sync-server/src/`: Rust server implementation with WebSocket handlers -- `frontend/sync-client/src/sync-client.ts`: Main sync client entry point -- `frontend/obsidian-plugin/src/vault-link-plugin.ts`: Main Obsidian plugin class -- `frontend/sync-client/src/services/sync-service.ts`: Core synchronization logic +**Server catch-up.** The server's WS handshake replays events newer than the client's `last_seen_vault_update_id` from the `latest_document_versions` view (one row per doc, the latest). On those replayed rows `is_new_file` means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`), not "this row is the doc's first version" — necessary because the catch-up only carries the latest version; if a doc was created and updated past the watermark, the client never sees its create otherwise. -## Testing +## Edge-case patterns the sync engine has to survive -### Running Tests -- Server: `cargo test --verbose` -- Frontend: `npm run test` (runs Jest across all workspaces) -- E2E: `scripts/e2e.sh` +The two-loop split defuses most of the old race catalogue (slot-collision stashes, conflict-uuid divergence, `MoveOnConflict.NEW`/`EXISTING` policy choices) by separating wire transport from path placement. What's left: -### Test Structure -- Rust: Unit tests alongside source files -- TypeScript: `.test.ts` files using Jest -- E2E: Uses test-client to simulate multiple concurrent users +**Pending-create docId is a `Promise`, not a string, until the create acks.** Any `LocalUpdate` / `LocalDelete` queued behind a still-in-flight `LocalCreate` carries the create's `resolvers.promise` as its `documentId`. `replacePendingDocumentId` swaps the resolved id across queued events when the create resolves; `===` comparisons against the resolved string elsewhere will silently fail until that swap runs. Anything that walks `events[]` looking for a docId match must either run after the swap or be tolerant of `Promise`-typed ids. -## Code Style +**`processCreate` reads `event.path` live, not `event.originalPath`.** The watcher rewrites `event.path` in place via `updatePendingCreatePath` when the user renames a pending-create file. `originalPath` was removed from `LocalCreate` events specifically because reading it would send the stale pre-rename path to the server. -### Rust -- Uses extensive Clippy lints (see Cargo.toml) -- Follows pedantic linting rules -- Forbids unsafe code -- Uses cargo fmt with default settings +**`record.localPath` mutates in place across awaits.** When the watcher renames a doc while a drain handler is awaiting an HTTP roundtrip, the queue mutates the in-flight event's record so subsequent reads see the new path. Snapshotting `record.localPath` into a local at function entry and using it after an `await` reads/writes a now-vacated slot. Read `record.localPath` live; only snapshot for the deliberate "did it change while I was awaiting" comparison. -### TypeScript -- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings -- ESLint with unused imports plugin -- Consistent across all three frontend packages +**Reconciler-defer is the wire-loop's contract with the reconciler.** The reconciler skips records where `hasPendingLocalEventsForDocumentId` returns true. Wire-loop handlers can therefore freely write `remoteRelativePath` to whatever the server returned — even if it disagrees with `localPath` — knowing the reconciler won't move the file out from under a queued user rename. + +**Watermark advancement is load-bearing both ways.** Branches that _skip_ a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that _advance_ without applying the content lose data: the server has no further event to re-deliver, the catch-up only carries the latest version, and any state in between is gone. Don't advance unless the event was actually applied (or deliberately discarded after weighing both halves). + +**`isNewFile` semantics differ between catch-up and real-time.** On WS handshake replay it means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`); on real-time broadcasts it means _this version is the create_ (`creation_vault_update_id == vault_update_id`). A handler that decides based on one interpretation will be wrong on the other channel; reasoning about fetch-and-treat-as-new vs. ignore needs to know which channel delivered the event. + +**Pause / disable-sync mid-flight** is the one race the new model doesn't structurally fix. An HTTP that committed server-side but whose response was discarded leaves the server holding a doc the client has no record of. Resume → offline scan → server-side dedupe handles it (the server merges the duplicate create into the existing doc), but if the merge produces a deconflict, the client picks up an extra file. Out of scope for the two-loop split. + +**Cycle reconciliation uses in-memory content swap.** When the move graph contains a cycle, the reconciler reads every file in the cycle into memory and writes each back to its new slot, with no tmp files. A write-ahead marker at `.vaultlink/swap-.json` lists each leg; on startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. The `.vaultlink/**` glob is hard-coded as an internal ignore pattern so swap markers don't get sync'd. + +## Two complementary E2E harnesses + +- **`test-client` (fuzz):** random ops across N parallel processes for many minutes. Used by `scripts/e2e.sh`. Catches bugs nobody thought to write a test for, but reproductions are noisy. +- **`deterministic-tests`:** scripted scenarios with an in-memory FS pinned to a real server. Used to _capture_ a fuzz-discovered bug as a minimal repro before fixing it. See `frontend/deterministic-tests/README.md` for the step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`, etc.). + +When a fuzz failure surfaces, the workflow is: root-cause from logs → write a deterministic test that fails on the bug → fix → confirm both the deterministic test and `e2e.sh` pass. + +## Style + +- TS: 4-space indent, no tabs, LF, prettier (`trailingComma: "none"`). YAML/MD use 2-space indent. +- Rust: `rustfmt.toml` enforces 4-space spaces, LF. +- Lint: ESLint for TS, Clippy for Rust, `cargo machete` for unused deps. All wired into `scripts/check.sh`. diff --git a/README.md b/README.md index f5da9b61..74c6ee97 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ ## Develop -### Install [nvm](https://github.com/nvm-sh/nvm) +### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm) - `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` -- `nvm install 22` -- `nvm use 22` -- Optionally set the system-wide default: `nvm alias default 22` +- `nvm install 25` +- `nvm use 25` +- Optionally, set the system-wide default: `nvm alias default 25` ### Set up Rust diff --git a/docs/.cspell.json b/docs/.cspell.json index 4967ec16..1177e1e1 100644 --- a/docs/.cspell.json +++ b/docs/.cspell.json @@ -2,12 +2,7 @@ "version": "0.2", "language": "en-GB", "dictionaries": ["en-gb"], - "ignorePaths": [ - "node_modules", - ".vitepress/dist", - ".vitepress/cache", - "package-lock.json" - ], + "ignorePaths": ["node_modules", ".vitepress/dist", ".vitepress/cache", "package-lock.json"], "words": [ "VaultLink", "Obsidian", diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md index 832c5624..167be524 100644 --- a/docs/architecture/data-flow.md +++ b/docs/architecture/data-flow.md @@ -361,11 +361,11 @@ VALUES (?, ?, ?); ```json { - "type": "upload_file", - "path": "notes/example.md", - "content": "File content here...", - "base_version": 10, - "timestamp": "2024-01-01T12:00:00Z" + "type": "upload_file", + "path": "notes/example.md", + "content": "File content here...", + "base_version": 10, + "timestamp": "2024-01-01T12:00:00Z" } ``` @@ -373,8 +373,8 @@ VALUES (?, ?, ?); ```json { - "type": "download_file", - "path": "notes/example.md" + "type": "download_file", + "path": "notes/example.md" } ``` @@ -382,8 +382,8 @@ VALUES (?, ?, ?); ```json { - "type": "delete_file", - "path": "notes/old.md" + "type": "delete_file", + "path": "notes/old.md" } ``` @@ -391,8 +391,8 @@ VALUES (?, ?, ?); ```json { - "type": "list_files", - "since_version": 0 + "type": "list_files", + "since_version": 0 } ``` @@ -402,11 +402,11 @@ VALUES (?, ?, ?); ```json { - "type": "file_updated", - "path": "notes/example.md", - "version": 11, - "size": 1024, - "hash": "abc123..." + "type": "file_updated", + "path": "notes/example.md", + "version": 11, + "size": 1024, + "hash": "abc123..." } ``` @@ -414,10 +414,10 @@ VALUES (?, ?, ?); ```json { - "type": "file_content", - "path": "notes/example.md", - "content": "Updated content...", - "version": 11 + "type": "file_content", + "path": "notes/example.md", + "content": "Updated content...", + "version": 11 } ``` @@ -425,9 +425,9 @@ VALUES (?, ?, ?); ```json { - "type": "file_deleted", - "path": "notes/old.md", - "version": 12 + "type": "file_deleted", + "path": "notes/old.md", + "version": 12 } ``` @@ -435,9 +435,9 @@ VALUES (?, ?, ?); ```json { - "type": "sync_complete", - "total_files": 150, - "current_version": 200 + "type": "sync_complete", + "total_files": 150, + "current_version": 200 } ``` @@ -445,9 +445,9 @@ VALUES (?, ?, ?); ```json { - "type": "error", - "message": "File too large", - "code": "FILE_TOO_LARGE" + "type": "error", + "message": "File too large", + "code": "FILE_TOO_LARGE" } ``` diff --git a/docs/architecture/index.md b/docs/architecture/index.md index f5eca5e3..bebb6c49 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -53,7 +53,7 @@ Central authority for synchronisation. Rust + Axum framework. **Technology**: -- **Language**: Rust 1.89+ +- **Language**: Rust 1.92+ - **Framework**: Axum (async web framework) - **Database**: SQLite with SQLx - **Protocol**: WebSockets for real-time communication diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 11425b5b..74977be7 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -243,9 +243,9 @@ users: 2. Client sends authentication message: ```json { - "type": "auth", - "token": "user-token", - "vault": "vault-name" + "type": "auth", + "token": "user-token", + "vault": "vault-name" } ``` 3. Server validates: diff --git a/docs/guide/server-setup.md b/docs/guide/server-setup.md index 7754da54..1848db26 100644 --- a/docs/guide/server-setup.md +++ b/docs/guide/server-setup.md @@ -75,7 +75,7 @@ chmod +x sync_server-linux-x86_64 ### Build from Source -Requirements: Rust 1.89.0+, SQLite development headers, SQLx CLI +Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI ```bash # Clone the repository diff --git a/docs/package-lock.json b/docs/package-lock.json index dcd4f3b0..d078bbe6 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1,2989 +1,2989 @@ { - "name": "docs", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { "name": "docs", "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "@cspell/dict-en-gb": "^5.0.19", - "cspell": "^9.3.2", - "prettier": "^3.6.2", - "vitepress": "^1.6.4", - "vue": "^3.5.24" - } - }, - "node_modules/@algolia/abtesting": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.10.0.tgz", - "integrity": "sha512-mQT3jwuTgX8QMoqbIR7mPlWkqQqBPQaPabQzm37xg2txMlaMogK/4hCiiESGdg39MlHZOVHeV+0VJuE7f5UK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/autocomplete-core": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", - "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", - "@algolia/autocomplete-shared": "1.17.7" - } - }, - "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", - "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", - "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/autocomplete-shared": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", - "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.44.0.tgz", - "integrity": "sha512-KY5CcrWhRTUo/lV7KcyjrZkPOOF9bjgWpMj9z98VA+sXzVpZtkuskBLCKsWYFp2sbwchZFTd3wJM48H0IGgF7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.44.0.tgz", - "integrity": "sha512-LKOCE8S4ewI9bN3ot9RZoYASPi8b78E918/DVPW3HHjCMUe6i+NjbNG6KotU4RpP6AhRWZjjswbOkWelUO+OoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.44.0.tgz", - "integrity": "sha512-1yyJm4OYC2cztbS28XYVWwLXdwpLsMG4LoZLOltVglQ2+hc/i9q9fUDZyjRa2Bqt4DmkIfezagfMrokhyH4uxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.44.0.tgz", - "integrity": "sha512-wVQWK6jYYsbEOjIMI+e5voLGPUIbXrvDj392IckXaCPvQ6vCMTXakQqOYCd+znQdL76S+3wHDo77HZWiAYKrtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.44.0.tgz", - "integrity": "sha512-lkgRjOjOkqmIkebHjHpU9rLJcJNUDMm+eVSW/KJQYLjGqykEZxal+nYJJTBbLceEU2roByP/+27ZmgIwCdf0iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.44.0.tgz", - "integrity": "sha512-sYfhgwKu6NDVmZHL1WEKVLsOx/jUXCY4BHKLUOcYa8k4COCs6USGgz6IjFkUf+niwq8NCECMmTC4o/fVQOalsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.44.0.tgz", - "integrity": "sha512-/FRKUM1G4xn3vV8+9xH1WJ9XknU8rkBGlefruq9jDhYUAvYozKimhrmC2pRqw/RyHhPivmgZCRuC8jHP8piz4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/ingestion": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.44.0.tgz", - "integrity": "sha512-5+S5ynwMmpTpCLXGjTDpeIa81J+R4BLH0lAojOhmeGSeGEHQTqacl/4sbPyDTcidvnWhaqtyf8m42ue6lvISAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.44.0.tgz", - "integrity": "sha512-xhaTN8pXJjR6zkrecg4Cc9YZaQK2LKm2R+LkbAq+AYGBCWJxtSGlNwftozZzkUyq4AXWoyoc0x2SyBtq5LRtqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.44.0.tgz", - "integrity": "sha512-GNcite/uOIS7wgRU1MT7SdNIupGSW+vbK9igIzMePvD2Dl8dy0O3urKPKIbTuZQqiVH1Cb84y5cgLvwNrdCj/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.44.0.tgz", - "integrity": "sha512-YZHBk72Cd7pcuNHzbhNzF/FbbYszlc7JhZlDyQAchnX5S7tcemSS96F39Sy8t4O4WQLpFvUf1MTNedlitWdOsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.44.0.tgz", - "integrity": "sha512-B9WHl+wQ7uf46t9cq+vVM/ypVbOeuldVDq9OtKsX2ApL2g/htx6ImB9ugDOOJmB5+fE31/XPTuCcYz/j03+idA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.44.0.tgz", - "integrity": "sha512-MULm0qeAIk4cdzZ/ehJnl1o7uB5NMokg83/3MKhPq0Pk7+I0uELGNbzIfAkvkKKEYcHALemKdArtySF9eKzh/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@cspell/cspell-bundled-dicts": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-9.3.2.tgz", - "integrity": "sha512-OmKzq/0FATHU671GKMzBrTyLdm25Wnziva7h4ylumVn1wnwWsXGef5bgXD7iuApqfqH9SzxsU0NtTB8m8vwEHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/dict-ada": "^4.1.1", - "@cspell/dict-al": "^1.1.1", - "@cspell/dict-aws": "^4.0.16", - "@cspell/dict-bash": "^4.2.2", - "@cspell/dict-companies": "^3.2.7", - "@cspell/dict-cpp": "^6.0.14", - "@cspell/dict-cryptocurrencies": "^5.0.5", - "@cspell/dict-csharp": "^4.0.7", - "@cspell/dict-css": "^4.0.18", - "@cspell/dict-dart": "^2.3.1", - "@cspell/dict-data-science": "^2.0.11", - "@cspell/dict-django": "^4.1.5", - "@cspell/dict-docker": "^1.1.16", - "@cspell/dict-dotnet": "^5.0.10", - "@cspell/dict-elixir": "^4.0.8", - "@cspell/dict-en_us": "^4.4.24", - "@cspell/dict-en-common-misspellings": "^2.1.8", - "@cspell/dict-en-gb-mit": "^3.1.14", - "@cspell/dict-filetypes": "^3.0.14", - "@cspell/dict-flutter": "^1.1.1", - "@cspell/dict-fonts": "^4.0.5", - "@cspell/dict-fsharp": "^1.1.1", - "@cspell/dict-fullstack": "^3.2.7", - "@cspell/dict-gaming-terms": "^1.1.2", - "@cspell/dict-git": "^3.0.7", - "@cspell/dict-golang": "^6.0.24", - "@cspell/dict-google": "^1.0.9", - "@cspell/dict-haskell": "^4.0.6", - "@cspell/dict-html": "^4.0.12", - "@cspell/dict-html-symbol-entities": "^4.0.4", - "@cspell/dict-java": "^5.0.12", - "@cspell/dict-julia": "^1.1.1", - "@cspell/dict-k8s": "^1.0.12", - "@cspell/dict-kotlin": "^1.1.1", - "@cspell/dict-latex": "^4.0.4", - "@cspell/dict-lorem-ipsum": "^4.0.5", - "@cspell/dict-lua": "^4.0.8", - "@cspell/dict-makefile": "^1.0.5", - "@cspell/dict-markdown": "^2.0.12", - "@cspell/dict-monkeyc": "^1.0.11", - "@cspell/dict-node": "^5.0.8", - "@cspell/dict-npm": "^5.2.22", - "@cspell/dict-php": "^4.1.0", - "@cspell/dict-powershell": "^5.0.15", - "@cspell/dict-public-licenses": "^2.0.15", - "@cspell/dict-python": "^4.2.21", - "@cspell/dict-r": "^2.1.1", - "@cspell/dict-ruby": "^5.0.9", - "@cspell/dict-rust": "^4.0.12", - "@cspell/dict-scala": "^5.0.8", - "@cspell/dict-shell": "^1.1.2", - "@cspell/dict-software-terms": "^5.1.13", - "@cspell/dict-sql": "^2.2.1", - "@cspell/dict-svelte": "^1.0.7", - "@cspell/dict-swift": "^2.0.6", - "@cspell/dict-terraform": "^1.1.3", - "@cspell/dict-typescript": "^3.2.3", - "@cspell/dict-vue": "^3.0.5", - "@cspell/dict-zig": "^1.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@cspell/cspell-json-reporter": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-9.3.2.tgz", - "integrity": "sha512-YRgpeHN9uY8kUlIw9q+8zJ0tRTAJMbfBTGzCq9Puah09NeMWlRMFPUkXVrkdic6NA7etboZ+zEdoZwRO9EmhiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-types": "9.3.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@cspell/cspell-pipe": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-9.3.2.tgz", - "integrity": "sha512-REF7ibG79WLEynIMUss/IRDCdYEb1nlE1rj/gt2CbPFzLa6t5MRwW2lajEvXS6/WgbMtsTVHAWi3ALqJzCwxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/@cspell/cspell-resolver": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-9.3.2.tgz", - "integrity": "sha512-jLN2Aa/vxm8+IBvTd884SwPEfjxnDwIEPBT3hmqgLlKuUHQ3FMG27lsM4Ik9L2KWBXMgV/wGz4BaxfhKI41Ttw==", - "dev": true, - "license": "MIT", - "dependencies": { - "global-directory": "^4.0.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@cspell/cspell-service-bus": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-9.3.2.tgz", - "integrity": "sha512-/rB8LazM0JzKL+AvZa5fEpLutmwy5QFMpzw8HJd+rDGkzb5r79hURWSRo84QArgaskUqA9XlOHSieDE9pt+WAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/@cspell/cspell-types": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-9.3.2.tgz", - "integrity": "sha512-l4H8bMAmdzCbXHO8y1JZiAKszrPEiuLFKWrbhCacHF0iP+PIc/yuQp7cO70m0p70vArRfih6kgGyHFaCy47CfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/@cspell/dict-ada": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz", - "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-al": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz", - "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-aws": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.16.tgz", - "integrity": "sha512-a681zShZbtTo947NvTYGLer95ZDQw1ROKvIFydak1e0OlfFCsNdtcYTupn0nbbYs53c9AO7G2DU8AcNEAnwXPA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-bash": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.2.tgz", - "integrity": "sha512-kyWbwtX3TsCf5l49gGQIZkRLaB/P8g73GDRm41Zu8Mv51kjl2H7Au0TsEvHv7jzcsRLS6aUYaZv6Zsvk1fOz+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/dict-shell": "1.1.2" - } - }, - "node_modules/@cspell/dict-companies": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.7.tgz", - "integrity": "sha512-fEyr3LmpFKTaD0LcRhB4lfW1AmULYBqzg4gWAV0dQCv06l+TsA+JQ+3pZJbUcoaZirtgsgT3dL3RUjmGPhUH0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-cpp": { - "version": "6.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-6.0.14.tgz", - "integrity": "sha512-dkmpSwvVfVdtoZ4mW/CK2Ep1v8mJlp6uiKpMNbSMOdJl4kq28nQS4vKNIX3B2bJa0Ha5iHHu+1mNjiLeO3g7Xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-cryptocurrencies": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz", - "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-csharp": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.7.tgz", - "integrity": "sha512-H16Hpu8O/1/lgijFt2lOk4/nnldFtQ4t8QHbyqphqZZVE5aS4J/zD/WvduqnLY21aKhZS6jo/xF5PX9jyqPKUA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-css": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.18.tgz", - "integrity": "sha512-EF77RqROHL+4LhMGW5NTeKqfUd/e4OOv6EDFQ/UQQiFyWuqkEKyEz0NDILxOFxWUEVdjT2GQ2cC7t12B6pESwg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-dart": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.1.tgz", - "integrity": "sha512-xoiGnULEcWdodXI6EwVyqpZmpOoh8RA2Xk9BNdR7DLamV/QMvEYn8KJ7NlRiTSauJKPNkHHQ5EVHRM6sTS7jdg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-data-science": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.12.tgz", - "integrity": "sha512-vI/mg6cI28IkFcpeINS7cm5M9HWemmXSTnxJiu3nmc4VAGx35SXIEyuLGBcsVzySvDablFYf4hsEpmg1XpVsUQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-django": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.5.tgz", - "integrity": "sha512-AvTWu99doU3T8ifoMYOMLW2CXKvyKLukPh1auOPwFGHzueWYvBBN+OxF8wF7XwjTBMMeRleVdLh3aWCDEX/ZWg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-docker": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.16.tgz", - "integrity": "sha512-UiVQ5RmCg6j0qGIxrBnai3pIB+aYKL3zaJGvXk1O/ertTKJif9RZikKXCEgqhaCYMweM4fuLqWSVmw3hU164Iw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-dotnet": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.10.tgz", - "integrity": "sha512-ooar8BP/RBNP1gzYfJPStKEmpWy4uv/7JCq6FOnJLeD1yyfG3d/LFMVMwiJo+XWz025cxtkM3wuaikBWzCqkmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-elixir": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz", - "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-en_us": { - "version": "4.4.24", - "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.24.tgz", - "integrity": "sha512-JE+/H2YicHJTneRmgH4GSI21rS+1yGZVl1jfOQgl8iHLC+yTTMtCvueNDMK94CgJACzYAoCsQB70MqiFJJfjLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-en-common-misspellings": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.1.8.tgz", - "integrity": "sha512-vDsjRFPQGuAADAiitf82z9Mz3DcqKZi6V5hPAEIFkLLKjFVBcjUsSq59SfL59ElIFb76MtBO0BLifdEbBj+DoQ==", - "dev": true, - "license": "CC BY-SA 4.0" - }, - "node_modules/@cspell/dict-en-gb": { - "version": "5.0.19", - "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb/-/dict-en-gb-5.0.19.tgz", - "integrity": "sha512-/p+p/9q8XTzsE0GxbZZKcC1rTLYmCpilYw8aC9Q1xJbve8YqZnpxk8IxRyaHwfy1TeKMQNs6heZZRtzPag0rCw==", - "dev": true, - "license": "LGPL-3.0" - }, - "node_modules/@cspell/dict-en-gb-mit": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.14.tgz", - "integrity": "sha512-b+vEerlHP6rnNf30tmTJb7JZnOq4WAslYUvexOz/L3gDna9YJN3bAnwRJ3At3bdcOcMG7PTv3Pi+C73IR22lNg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-filetypes": { - "version": "3.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.14.tgz", - "integrity": "sha512-KSXaSMYYNMLLdHEnju1DyRRH3eQWPRYRnOXpuHUdOh2jC44VgQoxyMU7oB3NAhDhZKBPCihabzECsAGFbdKfEA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-flutter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz", - "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-fonts": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.5.tgz", - "integrity": "sha512-BbpkX10DUX/xzHs6lb7yzDf/LPjwYIBJHJlUXSBXDtK/1HaeS+Wqol4Mlm2+NAgZ7ikIE5DQMViTgBUY3ezNoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-fsharp": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz", - "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-fullstack": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.7.tgz", - "integrity": "sha512-IxEk2YAwAJKYCUEgEeOg3QvTL4XLlyArJElFuMQevU1dPgHgzWElFevN5lsTFnvMFA1riYsVinqJJX0BanCFEg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-gaming-terms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.2.tgz", - "integrity": "sha512-9XnOvaoTBscq0xuD6KTEIkk9hhdfBkkvJAIsvw3JMcnp1214OCGW8+kako5RqQ2vTZR3Tnf3pc57o7VgkM0q1Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-git": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.0.7.tgz", - "integrity": "sha512-odOwVKgfxCQfiSb+nblQZc4ErXmnWEnv8XwkaI4sNJ7cNmojnvogYVeMqkXPjvfrgEcizEEA4URRD2Ms5PDk1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-golang": { - "version": "6.0.24", - "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.24.tgz", - "integrity": "sha512-rY7PlC3MsHozmjrZWi0HQPUl0BVCV0+mwK0rnMT7pOIXqOe4tWCYMULDIsEk4F0gbIxb5badd2dkCPDYjLnDgA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-google": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz", - "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-haskell": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz", - "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-html": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.12.tgz", - "integrity": "sha512-JFffQ1dDVEyJq6tCDWv0r/RqkdSnV43P2F/3jJ9rwLgdsOIXwQbXrz6QDlvQLVvNSnORH9KjDtenFTGDyzfCaA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-html-symbol-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.4.tgz", - "integrity": "sha512-afea+0rGPDeOV9gdO06UW183Qg6wRhWVkgCFwiO3bDupAoyXRuvupbb5nUyqSTsLXIKL8u8uXQlJ9pkz07oVXw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-java": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz", - "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-julia": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz", - "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-k8s": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.12.tgz", - "integrity": "sha512-2LcllTWgaTfYC7DmkMPOn9GsBWsA4DZdlun4po8s2ysTP7CPEnZc1ZfK6pZ2eI4TsZemlUQQ+NZxMe9/QutQxg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-kotlin": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz", - "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-latex": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-4.0.4.tgz", - "integrity": "sha512-YdTQhnTINEEm/LZgTzr9Voz4mzdOXH7YX+bSFs3hnkUHCUUtX/mhKgf1CFvZ0YNM2afjhQcmLaR9bDQVyYBvpA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-lorem-ipsum": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz", - "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-lua": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz", - "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-makefile": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz", - "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-markdown": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.12.tgz", - "integrity": "sha512-ufwoliPijAgWkD/ivAMC+A9QD895xKiJRF/fwwknQb7kt7NozTLKFAOBtXGPJAB4UjhGBpYEJVo2elQ0FCAH9A==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@cspell/dict-css": "^4.0.18", - "@cspell/dict-html": "^4.0.12", - "@cspell/dict-html-symbol-entities": "^4.0.4", - "@cspell/dict-typescript": "^3.2.3" - } - }, - "node_modules/@cspell/dict-monkeyc": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.0.11.tgz", - "integrity": "sha512-7Q1Ncu0urALI6dPTrEbSTd//UK0qjRBeaxhnm8uY5fgYNFYAG+u4gtnTIo59S6Bw5P++4H3DiIDYoQdY/lha8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-node": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.8.tgz", - "integrity": "sha512-AirZcN2i84ynev3p2/1NCPEhnNsHKMz9zciTngGoqpdItUb2bDt1nJBjwlsrFI78GZRph/VaqTVFwYikmncpXg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-npm": { - "version": "5.2.23", - "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.23.tgz", - "integrity": "sha512-cnlPGzhNkbXFLFURfjzwML2LjHMofqJkemR7lLo9Jwa9IptvzeTn4nOtJMSGfkxNrZPf/IvQ7rH5hamsUQLQ3A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-php": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.0.tgz", - "integrity": "sha512-dTDeabyOj7eFvn2Q4Za3uVXM2+SzeFMqX8ly2P0XTo4AzbCmI2hulFD/QIADwWmwiRrInbbf8cxwFHNIYrXl4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-powershell": { - "version": "5.0.15", - "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.15.tgz", - "integrity": "sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-public-licenses": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.15.tgz", - "integrity": "sha512-cJEOs901H13Pfy0fl4dCD1U+xpWIMaEPq8MeYU83FfDZvellAuSo4GqWCripfIqlhns/L6+UZEIJSOZnjgy7Wg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-python": { - "version": "4.2.22", - "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.22.tgz", - "integrity": "sha512-rgF7DuleVK2lkzlw33jjEfxS2a0CU5kwAhOqf5B6XkuaPbqZ/0g0LBCdwglAGccYu7sBuvxRS8Yubk+ytSAFTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/dict-data-science": "^2.0.12" - } - }, - "node_modules/@cspell/dict-r": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz", - "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-ruby": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.0.9.tgz", - "integrity": "sha512-H2vMcERMcANvQshAdrVx0XoWaNX8zmmiQN11dZZTQAZaNJ0xatdJoSqY8C8uhEMW89bfgpN+NQgGuDXW2vmXEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-rust": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.0.12.tgz", - "integrity": "sha512-z2QiH+q9UlNhobBJArvILRxV8Jz0pKIK7gqu4TgmEYyjiu1TvnGZ1tbYHeu9w3I/wOP6UMDoCBTty5AlYfW0mw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-scala": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.8.tgz", - "integrity": "sha512-YdftVmumv8IZq9zu1gn2U7A4bfM2yj9Vaupydotyjuc+EEZZSqAafTpvW/jKLWji2TgybM1L2IhmV0s/Iv9BTw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-shell": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.1.2.tgz", - "integrity": "sha512-WqOUvnwcHK1X61wAfwyXq04cn7KYyskg90j4lLg3sGGKMW9Sq13hs91pqrjC44Q+lQLgCobrTkMDw9Wyl9nRFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-software-terms": { - "version": "5.1.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.1.14.tgz", - "integrity": "sha512-Eu9h090hxHJiqzVFS0WxOZbYXnmb7F1RFIUEg4Nru+D/78bXVDH4b8BiKGVFNRljaieNQRAHaryzdaKJRCH6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-sql": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz", - "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-svelte": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz", - "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-swift": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz", - "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-terraform": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.3.tgz", - "integrity": "sha512-gr6wxCydwSFyyBKhBA2xkENXtVFToheqYYGFvlMZXWjviynXmh+NK/JTvTCk/VHk3+lzbO9EEQKee6VjrAUSbA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-typescript": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", - "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-vue": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz", - "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dict-zig": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@cspell/dict-zig/-/dict-zig-1.0.0.tgz", - "integrity": "sha512-XibBIxBlVosU06+M6uHWkFeT0/pW5WajDRYdXG2CgHnq85b0TI/Ks0FuBJykmsgi2CAD3Qtx8UHFEtl/DSFnAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspell/dynamic-import": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-9.3.2.tgz", - "integrity": "sha512-au7FyuIHUNI2r9sO3pUBKVTeD/v7c9x/nPUStaAK1bG4rdKt4w+/jUY2IaldAraW5w29z528BboXbiV87SM1kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/url": "9.3.2", - "import-meta-resolve": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@cspell/filetypes": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.3.2.tgz", - "integrity": "sha512-0bUxQlmJPRHZrRQD7adbc4lFizO8tGD/6+1cBgU3kV3+NVrpr12y4jU8twCSChhYibZyPr7bnvhkM3cQgb8RzA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/@cspell/strong-weak-map": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-9.3.2.tgz", - "integrity": "sha512-pFcmOTWCoFMRETb9PCkCmaiZiLb5i2qOZmGH/p/tFEH8kIYhMGfhaulnXwKwS+Ke6PKceQd2YL98bGmo8hL4aQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/@cspell/url": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@cspell/url/-/url-9.3.2.tgz", - "integrity": "sha512-TobUlZl7Z7VehhNOMNAg1ABuGizieseftlG94OZJ934JptOhK8TC/1o2ldKrbDH50jyt6E7rPTMV2BW/vWuTzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/@docsearch/css": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", - "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@docsearch/js": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", - "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/react": "3.8.2", - "preact": "^10.0.0" - } - }, - "node_modules/@docsearch/react": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", - "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-core": "1.17.7", - "@algolia/autocomplete-preset-algolia": "1.17.7", - "@docsearch/css": "3.8.2", - "algoliasearch": "^5.14.2" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 19.0.0", - "react": ">= 16.8.0 < 19.0.0", - "react-dom": ">= 16.8.0 < 19.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docs", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@cspell/dict-en-gb": "^5.0.19", + "cspell": "^9.3.2", + "prettier": "^3.6.2", + "vitepress": "^1.6.4", + "vue": "^3.5.24" + } }, - "react": { - "optional": true + "node_modules/@algolia/abtesting": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.10.0.tgz", + "integrity": "sha512-mQT3jwuTgX8QMoqbIR7mPlWkqQqBPQaPabQzm37xg2txMlaMogK/4hCiiESGdg39MlHZOVHeV+0VJuE7f5UK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } }, - "react-dom": { - "optional": true + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } }, - "search-insights": { - "optional": true + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.44.0.tgz", + "integrity": "sha512-KY5CcrWhRTUo/lV7KcyjrZkPOOF9bjgWpMj9z98VA+sXzVpZtkuskBLCKsWYFp2sbwchZFTd3wJM48H0IGgF7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.44.0.tgz", + "integrity": "sha512-LKOCE8S4ewI9bN3ot9RZoYASPi8b78E918/DVPW3HHjCMUe6i+NjbNG6KotU4RpP6AhRWZjjswbOkWelUO+OoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.44.0.tgz", + "integrity": "sha512-1yyJm4OYC2cztbS28XYVWwLXdwpLsMG4LoZLOltVglQ2+hc/i9q9fUDZyjRa2Bqt4DmkIfezagfMrokhyH4uxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.44.0.tgz", + "integrity": "sha512-wVQWK6jYYsbEOjIMI+e5voLGPUIbXrvDj392IckXaCPvQ6vCMTXakQqOYCd+znQdL76S+3wHDo77HZWiAYKrtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.44.0.tgz", + "integrity": "sha512-lkgRjOjOkqmIkebHjHpU9rLJcJNUDMm+eVSW/KJQYLjGqykEZxal+nYJJTBbLceEU2roByP/+27ZmgIwCdf0iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.44.0.tgz", + "integrity": "sha512-sYfhgwKu6NDVmZHL1WEKVLsOx/jUXCY4BHKLUOcYa8k4COCs6USGgz6IjFkUf+niwq8NCECMmTC4o/fVQOalsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.44.0.tgz", + "integrity": "sha512-/FRKUM1G4xn3vV8+9xH1WJ9XknU8rkBGlefruq9jDhYUAvYozKimhrmC2pRqw/RyHhPivmgZCRuC8jHP8piz4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.44.0.tgz", + "integrity": "sha512-5+S5ynwMmpTpCLXGjTDpeIa81J+R4BLH0lAojOhmeGSeGEHQTqacl/4sbPyDTcidvnWhaqtyf8m42ue6lvISAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.44.0.tgz", + "integrity": "sha512-xhaTN8pXJjR6zkrecg4Cc9YZaQK2LKm2R+LkbAq+AYGBCWJxtSGlNwftozZzkUyq4AXWoyoc0x2SyBtq5LRtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.44.0.tgz", + "integrity": "sha512-GNcite/uOIS7wgRU1MT7SdNIupGSW+vbK9igIzMePvD2Dl8dy0O3urKPKIbTuZQqiVH1Cb84y5cgLvwNrdCj/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.44.0.tgz", + "integrity": "sha512-YZHBk72Cd7pcuNHzbhNzF/FbbYszlc7JhZlDyQAchnX5S7tcemSS96F39Sy8t4O4WQLpFvUf1MTNedlitWdOsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.44.0.tgz", + "integrity": "sha512-B9WHl+wQ7uf46t9cq+vVM/ypVbOeuldVDq9OtKsX2ApL2g/htx6ImB9ugDOOJmB5+fE31/XPTuCcYz/j03+idA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.44.0.tgz", + "integrity": "sha512-MULm0qeAIk4cdzZ/ehJnl1o7uB5NMokg83/3MKhPq0Pk7+I0uELGNbzIfAkvkKKEYcHALemKdArtySF9eKzh/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cspell/cspell-bundled-dicts": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-9.3.2.tgz", + "integrity": "sha512-OmKzq/0FATHU671GKMzBrTyLdm25Wnziva7h4ylumVn1wnwWsXGef5bgXD7iuApqfqH9SzxsU0NtTB8m8vwEHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-ada": "^4.1.1", + "@cspell/dict-al": "^1.1.1", + "@cspell/dict-aws": "^4.0.16", + "@cspell/dict-bash": "^4.2.2", + "@cspell/dict-companies": "^3.2.7", + "@cspell/dict-cpp": "^6.0.14", + "@cspell/dict-cryptocurrencies": "^5.0.5", + "@cspell/dict-csharp": "^4.0.7", + "@cspell/dict-css": "^4.0.18", + "@cspell/dict-dart": "^2.3.1", + "@cspell/dict-data-science": "^2.0.11", + "@cspell/dict-django": "^4.1.5", + "@cspell/dict-docker": "^1.1.16", + "@cspell/dict-dotnet": "^5.0.10", + "@cspell/dict-elixir": "^4.0.8", + "@cspell/dict-en_us": "^4.4.24", + "@cspell/dict-en-common-misspellings": "^2.1.8", + "@cspell/dict-en-gb-mit": "^3.1.14", + "@cspell/dict-filetypes": "^3.0.14", + "@cspell/dict-flutter": "^1.1.1", + "@cspell/dict-fonts": "^4.0.5", + "@cspell/dict-fsharp": "^1.1.1", + "@cspell/dict-fullstack": "^3.2.7", + "@cspell/dict-gaming-terms": "^1.1.2", + "@cspell/dict-git": "^3.0.7", + "@cspell/dict-golang": "^6.0.24", + "@cspell/dict-google": "^1.0.9", + "@cspell/dict-haskell": "^4.0.6", + "@cspell/dict-html": "^4.0.12", + "@cspell/dict-html-symbol-entities": "^4.0.4", + "@cspell/dict-java": "^5.0.12", + "@cspell/dict-julia": "^1.1.1", + "@cspell/dict-k8s": "^1.0.12", + "@cspell/dict-kotlin": "^1.1.1", + "@cspell/dict-latex": "^4.0.4", + "@cspell/dict-lorem-ipsum": "^4.0.5", + "@cspell/dict-lua": "^4.0.8", + "@cspell/dict-makefile": "^1.0.5", + "@cspell/dict-markdown": "^2.0.12", + "@cspell/dict-monkeyc": "^1.0.11", + "@cspell/dict-node": "^5.0.8", + "@cspell/dict-npm": "^5.2.22", + "@cspell/dict-php": "^4.1.0", + "@cspell/dict-powershell": "^5.0.15", + "@cspell/dict-public-licenses": "^2.0.15", + "@cspell/dict-python": "^4.2.21", + "@cspell/dict-r": "^2.1.1", + "@cspell/dict-ruby": "^5.0.9", + "@cspell/dict-rust": "^4.0.12", + "@cspell/dict-scala": "^5.0.8", + "@cspell/dict-shell": "^1.1.2", + "@cspell/dict-software-terms": "^5.1.13", + "@cspell/dict-sql": "^2.2.1", + "@cspell/dict-svelte": "^1.0.7", + "@cspell/dict-swift": "^2.0.6", + "@cspell/dict-terraform": "^1.1.3", + "@cspell/dict-typescript": "^3.2.3", + "@cspell/dict-vue": "^3.0.5", + "@cspell/dict-zig": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-json-reporter": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-9.3.2.tgz", + "integrity": "sha512-YRgpeHN9uY8kUlIw9q+8zJ0tRTAJMbfBTGzCq9Puah09NeMWlRMFPUkXVrkdic6NA7etboZ+zEdoZwRO9EmhiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.3.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-pipe": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-9.3.2.tgz", + "integrity": "sha512-REF7ibG79WLEynIMUss/IRDCdYEb1nlE1rj/gt2CbPFzLa6t5MRwW2lajEvXS6/WgbMtsTVHAWi3ALqJzCwxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-resolver": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-9.3.2.tgz", + "integrity": "sha512-jLN2Aa/vxm8+IBvTd884SwPEfjxnDwIEPBT3hmqgLlKuUHQ3FMG27lsM4Ik9L2KWBXMgV/wGz4BaxfhKI41Ttw==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-service-bus": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-9.3.2.tgz", + "integrity": "sha512-/rB8LazM0JzKL+AvZa5fEpLutmwy5QFMpzw8HJd+rDGkzb5r79hURWSRo84QArgaskUqA9XlOHSieDE9pt+WAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-types": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-9.3.2.tgz", + "integrity": "sha512-l4H8bMAmdzCbXHO8y1JZiAKszrPEiuLFKWrbhCacHF0iP+PIc/yuQp7cO70m0p70vArRfih6kgGyHFaCy47CfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/dict-ada": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz", + "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-al": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz", + "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-aws": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.16.tgz", + "integrity": "sha512-a681zShZbtTo947NvTYGLer95ZDQw1ROKvIFydak1e0OlfFCsNdtcYTupn0nbbYs53c9AO7G2DU8AcNEAnwXPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-bash": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.2.tgz", + "integrity": "sha512-kyWbwtX3TsCf5l49gGQIZkRLaB/P8g73GDRm41Zu8Mv51kjl2H7Au0TsEvHv7jzcsRLS6aUYaZv6Zsvk1fOz+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-shell": "1.1.2" + } + }, + "node_modules/@cspell/dict-companies": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.7.tgz", + "integrity": "sha512-fEyr3LmpFKTaD0LcRhB4lfW1AmULYBqzg4gWAV0dQCv06l+TsA+JQ+3pZJbUcoaZirtgsgT3dL3RUjmGPhUH0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cpp": { + "version": "6.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-6.0.14.tgz", + "integrity": "sha512-dkmpSwvVfVdtoZ4mW/CK2Ep1v8mJlp6uiKpMNbSMOdJl4kq28nQS4vKNIX3B2bJa0Ha5iHHu+1mNjiLeO3g7Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cryptocurrencies": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz", + "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-csharp": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.7.tgz", + "integrity": "sha512-H16Hpu8O/1/lgijFt2lOk4/nnldFtQ4t8QHbyqphqZZVE5aS4J/zD/WvduqnLY21aKhZS6jo/xF5PX9jyqPKUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-css": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.18.tgz", + "integrity": "sha512-EF77RqROHL+4LhMGW5NTeKqfUd/e4OOv6EDFQ/UQQiFyWuqkEKyEz0NDILxOFxWUEVdjT2GQ2cC7t12B6pESwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dart": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.1.tgz", + "integrity": "sha512-xoiGnULEcWdodXI6EwVyqpZmpOoh8RA2Xk9BNdR7DLamV/QMvEYn8KJ7NlRiTSauJKPNkHHQ5EVHRM6sTS7jdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-data-science": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.12.tgz", + "integrity": "sha512-vI/mg6cI28IkFcpeINS7cm5M9HWemmXSTnxJiu3nmc4VAGx35SXIEyuLGBcsVzySvDablFYf4hsEpmg1XpVsUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-django": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.5.tgz", + "integrity": "sha512-AvTWu99doU3T8ifoMYOMLW2CXKvyKLukPh1auOPwFGHzueWYvBBN+OxF8wF7XwjTBMMeRleVdLh3aWCDEX/ZWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-docker": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.16.tgz", + "integrity": "sha512-UiVQ5RmCg6j0qGIxrBnai3pIB+aYKL3zaJGvXk1O/ertTKJif9RZikKXCEgqhaCYMweM4fuLqWSVmw3hU164Iw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dotnet": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.10.tgz", + "integrity": "sha512-ooar8BP/RBNP1gzYfJPStKEmpWy4uv/7JCq6FOnJLeD1yyfG3d/LFMVMwiJo+XWz025cxtkM3wuaikBWzCqkmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-elixir": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz", + "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en_us": { + "version": "4.4.24", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.24.tgz", + "integrity": "sha512-JE+/H2YicHJTneRmgH4GSI21rS+1yGZVl1jfOQgl8iHLC+yTTMtCvueNDMK94CgJACzYAoCsQB70MqiFJJfjLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en-common-misspellings": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.1.8.tgz", + "integrity": "sha512-vDsjRFPQGuAADAiitf82z9Mz3DcqKZi6V5hPAEIFkLLKjFVBcjUsSq59SfL59ElIFb76MtBO0BLifdEbBj+DoQ==", + "dev": true, + "license": "CC BY-SA 4.0" + }, + "node_modules/@cspell/dict-en-gb": { + "version": "5.0.19", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb/-/dict-en-gb-5.0.19.tgz", + "integrity": "sha512-/p+p/9q8XTzsE0GxbZZKcC1rTLYmCpilYw8aC9Q1xJbve8YqZnpxk8IxRyaHwfy1TeKMQNs6heZZRtzPag0rCw==", + "dev": true, + "license": "LGPL-3.0" + }, + "node_modules/@cspell/dict-en-gb-mit": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.14.tgz", + "integrity": "sha512-b+vEerlHP6rnNf30tmTJb7JZnOq4WAslYUvexOz/L3gDna9YJN3bAnwRJ3At3bdcOcMG7PTv3Pi+C73IR22lNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-filetypes": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.14.tgz", + "integrity": "sha512-KSXaSMYYNMLLdHEnju1DyRRH3eQWPRYRnOXpuHUdOh2jC44VgQoxyMU7oB3NAhDhZKBPCihabzECsAGFbdKfEA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-flutter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz", + "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fonts": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.5.tgz", + "integrity": "sha512-BbpkX10DUX/xzHs6lb7yzDf/LPjwYIBJHJlUXSBXDtK/1HaeS+Wqol4Mlm2+NAgZ7ikIE5DQMViTgBUY3ezNoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fsharp": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz", + "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fullstack": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.7.tgz", + "integrity": "sha512-IxEk2YAwAJKYCUEgEeOg3QvTL4XLlyArJElFuMQevU1dPgHgzWElFevN5lsTFnvMFA1riYsVinqJJX0BanCFEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-gaming-terms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.2.tgz", + "integrity": "sha512-9XnOvaoTBscq0xuD6KTEIkk9hhdfBkkvJAIsvw3JMcnp1214OCGW8+kako5RqQ2vTZR3Tnf3pc57o7VgkM0q1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-git": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.0.7.tgz", + "integrity": "sha512-odOwVKgfxCQfiSb+nblQZc4ErXmnWEnv8XwkaI4sNJ7cNmojnvogYVeMqkXPjvfrgEcizEEA4URRD2Ms5PDk1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-golang": { + "version": "6.0.24", + "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.24.tgz", + "integrity": "sha512-rY7PlC3MsHozmjrZWi0HQPUl0BVCV0+mwK0rnMT7pOIXqOe4tWCYMULDIsEk4F0gbIxb5badd2dkCPDYjLnDgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-google": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz", + "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-haskell": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz", + "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.12.tgz", + "integrity": "sha512-JFffQ1dDVEyJq6tCDWv0r/RqkdSnV43P2F/3jJ9rwLgdsOIXwQbXrz6QDlvQLVvNSnORH9KjDtenFTGDyzfCaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html-symbol-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.4.tgz", + "integrity": "sha512-afea+0rGPDeOV9gdO06UW183Qg6wRhWVkgCFwiO3bDupAoyXRuvupbb5nUyqSTsLXIKL8u8uXQlJ9pkz07oVXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-java": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz", + "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-julia": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz", + "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-k8s": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.12.tgz", + "integrity": "sha512-2LcllTWgaTfYC7DmkMPOn9GsBWsA4DZdlun4po8s2ysTP7CPEnZc1ZfK6pZ2eI4TsZemlUQQ+NZxMe9/QutQxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-kotlin": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz", + "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-latex": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-4.0.4.tgz", + "integrity": "sha512-YdTQhnTINEEm/LZgTzr9Voz4mzdOXH7YX+bSFs3hnkUHCUUtX/mhKgf1CFvZ0YNM2afjhQcmLaR9bDQVyYBvpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lorem-ipsum": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz", + "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lua": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz", + "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-makefile": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz", + "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-markdown": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.12.tgz", + "integrity": "sha512-ufwoliPijAgWkD/ivAMC+A9QD895xKiJRF/fwwknQb7kt7NozTLKFAOBtXGPJAB4UjhGBpYEJVo2elQ0FCAH9A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cspell/dict-css": "^4.0.18", + "@cspell/dict-html": "^4.0.12", + "@cspell/dict-html-symbol-entities": "^4.0.4", + "@cspell/dict-typescript": "^3.2.3" + } + }, + "node_modules/@cspell/dict-monkeyc": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.0.11.tgz", + "integrity": "sha512-7Q1Ncu0urALI6dPTrEbSTd//UK0qjRBeaxhnm8uY5fgYNFYAG+u4gtnTIo59S6Bw5P++4H3DiIDYoQdY/lha8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-node": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.8.tgz", + "integrity": "sha512-AirZcN2i84ynev3p2/1NCPEhnNsHKMz9zciTngGoqpdItUb2bDt1nJBjwlsrFI78GZRph/VaqTVFwYikmncpXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-npm": { + "version": "5.2.23", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.23.tgz", + "integrity": "sha512-cnlPGzhNkbXFLFURfjzwML2LjHMofqJkemR7lLo9Jwa9IptvzeTn4nOtJMSGfkxNrZPf/IvQ7rH5hamsUQLQ3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-php": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.0.tgz", + "integrity": "sha512-dTDeabyOj7eFvn2Q4Za3uVXM2+SzeFMqX8ly2P0XTo4AzbCmI2hulFD/QIADwWmwiRrInbbf8cxwFHNIYrXl4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-powershell": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.15.tgz", + "integrity": "sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-public-licenses": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.15.tgz", + "integrity": "sha512-cJEOs901H13Pfy0fl4dCD1U+xpWIMaEPq8MeYU83FfDZvellAuSo4GqWCripfIqlhns/L6+UZEIJSOZnjgy7Wg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-python": { + "version": "4.2.22", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.22.tgz", + "integrity": "sha512-rgF7DuleVK2lkzlw33jjEfxS2a0CU5kwAhOqf5B6XkuaPbqZ/0g0LBCdwglAGccYu7sBuvxRS8Yubk+ytSAFTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-data-science": "^2.0.12" + } + }, + "node_modules/@cspell/dict-r": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz", + "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-ruby": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.0.9.tgz", + "integrity": "sha512-H2vMcERMcANvQshAdrVx0XoWaNX8zmmiQN11dZZTQAZaNJ0xatdJoSqY8C8uhEMW89bfgpN+NQgGuDXW2vmXEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-rust": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.0.12.tgz", + "integrity": "sha512-z2QiH+q9UlNhobBJArvILRxV8Jz0pKIK7gqu4TgmEYyjiu1TvnGZ1tbYHeu9w3I/wOP6UMDoCBTty5AlYfW0mw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-scala": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.8.tgz", + "integrity": "sha512-YdftVmumv8IZq9zu1gn2U7A4bfM2yj9Vaupydotyjuc+EEZZSqAafTpvW/jKLWji2TgybM1L2IhmV0s/Iv9BTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-shell": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.1.2.tgz", + "integrity": "sha512-WqOUvnwcHK1X61wAfwyXq04cn7KYyskg90j4lLg3sGGKMW9Sq13hs91pqrjC44Q+lQLgCobrTkMDw9Wyl9nRFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-software-terms": { + "version": "5.1.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.1.14.tgz", + "integrity": "sha512-Eu9h090hxHJiqzVFS0WxOZbYXnmb7F1RFIUEg4Nru+D/78bXVDH4b8BiKGVFNRljaieNQRAHaryzdaKJRCH6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-sql": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz", + "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-svelte": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz", + "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-swift": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz", + "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-terraform": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.3.tgz", + "integrity": "sha512-gr6wxCydwSFyyBKhBA2xkENXtVFToheqYYGFvlMZXWjviynXmh+NK/JTvTCk/VHk3+lzbO9EEQKee6VjrAUSbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-typescript": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", + "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-vue": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz", + "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-zig": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-zig/-/dict-zig-1.0.0.tgz", + "integrity": "sha512-XibBIxBlVosU06+M6uHWkFeT0/pW5WajDRYdXG2CgHnq85b0TI/Ks0FuBJykmsgi2CAD3Qtx8UHFEtl/DSFnAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dynamic-import": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-9.3.2.tgz", + "integrity": "sha512-au7FyuIHUNI2r9sO3pUBKVTeD/v7c9x/nPUStaAK1bG4rdKt4w+/jUY2IaldAraW5w29z528BboXbiV87SM1kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.3.2", + "import-meta-resolve": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/filetypes": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.3.2.tgz", + "integrity": "sha512-0bUxQlmJPRHZrRQD7adbc4lFizO8tGD/6+1cBgU3kV3+NVrpr12y4jU8twCSChhYibZyPr7bnvhkM3cQgb8RzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/strong-weak-map": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-9.3.2.tgz", + "integrity": "sha512-pFcmOTWCoFMRETb9PCkCmaiZiLb5i2qOZmGH/p/tFEH8kIYhMGfhaulnXwKwS+Ke6PKceQd2YL98bGmo8hL4aQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/url": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/url/-/url-9.3.2.tgz", + "integrity": "sha512-TobUlZl7Z7VehhNOMNAg1ABuGizieseftlG94OZJ934JptOhK8TC/1o2ldKrbDH50jyt6E7rPTMV2BW/vWuTzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.59", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.59.tgz", + "integrity": "sha512-fYx/InyQsWFW4wVxWka3CGDJ6m/fXoTqWBSl+oA3FBXO5RhPAb6S3Y5bRgCPnrYevErH8VjAL0TZevIqlN2PhQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", + "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.24", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz", + "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz", + "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.24", + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz", + "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", + "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", + "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", + "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.24", + "@vue/runtime-core": "3.5.24", + "@vue/shared": "3.5.24", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", + "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "vue": "3.5.24" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", + "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.44.0.tgz", + "integrity": "sha512-f8IpsbdQjzTjr/4mJ/jv5UplrtyMnnciGax6/B0OnLCs2/GJTK13O4Y7Ff1AvJVAaztanH+m5nzPoUq6EAy+aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.10.0", + "@algolia/client-abtesting": "5.44.0", + "@algolia/client-analytics": "5.44.0", + "@algolia/client-common": "5.44.0", + "@algolia/client-insights": "5.44.0", + "@algolia/client-personalization": "5.44.0", + "@algolia/client-query-suggestions": "5.44.0", + "@algolia/client-search": "5.44.0", + "@algolia/ingestion": "1.44.0", + "@algolia/monitoring": "1.44.0", + "@algolia/recommend": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz", + "integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.2.tgz", + "integrity": "sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/clear-module": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz", + "integrity": "sha512-LWAxzHqdHsAZlPlEyJ2Poz6AIs384mPeqLVCru2p0BrP9G/kVGuhNyZYClLO6cXlnuJjzC8xtsJIuMjKqLXoAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^2.0.0", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/comment-json": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.4.1.tgz", + "integrity": "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cspell": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell/-/cspell-9.3.2.tgz", + "integrity": "sha512-3xFyVSTYrYa/QJzLfzsCRMkMXqOsytP8E26DuGrVMJQoLPFmbOXNNtnMu4wrtr17QVloxpvutW77U4vb2L/LDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-json-reporter": "9.3.2", + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-types": "9.3.2", + "@cspell/dynamic-import": "9.3.2", + "@cspell/url": "9.3.2", + "chalk": "^5.6.2", + "chalk-template": "^1.1.2", + "commander": "^14.0.2", + "cspell-config-lib": "9.3.2", + "cspell-dictionary": "9.3.2", + "cspell-gitignore": "9.3.2", + "cspell-glob": "9.3.2", + "cspell-io": "9.3.2", + "cspell-lib": "9.3.2", + "fast-json-stable-stringify": "^2.1.0", + "flatted": "^3.3.3", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15" + }, + "bin": { + "cspell": "bin.mjs", + "cspell-esm": "bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/streetsidesoftware/cspell?sponsor=1" + } + }, + "node_modules/cspell-config-lib": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-9.3.2.tgz", + "integrity": "sha512-zXhmA4rqgWQRTVijI+g/mgiep76TvTO4d+P3CHwcqLG57BKVzoW+jkO4qDLC+Neh4b8+CcNWEIr3w16BfuEJAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.3.2", + "comment-json": "^4.4.1", + "smol-toml": "^1.5.2", + "yaml": "^2.8.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-dictionary": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-9.3.2.tgz", + "integrity": "sha512-E3YhOhZzZt1a+AEbFV2B3THCyZ576PDg0mDNUDrU1Y65SyIhf4DC6itfPoAb6R3FI/DI218RqWZg/FTT8lJ2gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-types": "9.3.2", + "cspell-trie-lib": "9.3.2", + "fast-equals": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-gitignore": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-9.3.2.tgz", + "integrity": "sha512-G2bLR+Dfb9GX4Sdm75GfCCa9V/sQYkRbLckuCuVmJxvcDB0xfczAtb6TfAXIziF3oUI6cOB1g+PoNLWBelcK5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.3.2", + "cspell-glob": "9.3.2", + "cspell-io": "9.3.2" + }, + "bin": { + "cspell-gitignore": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-glob": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-9.3.2.tgz", + "integrity": "sha512-TuSupENEKyOCupOUZ3vnPxaTOghxY/rD1JIkb8e5kjzRprYVilO/rYqEk/52iLwJVd+4Npe8fNhR3KhU7u/UUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.3.2", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-grammar": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-9.3.2.tgz", + "integrity": "sha512-ysonrFu9vJvF/derDlEjUfmvLeCfNOWPh00t6Yh093AKrJFoWQiyaS/5bEN/uB5/n1sa4k3ItnWvuTp3+YuZsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-types": "9.3.2" + }, + "bin": { + "cspell-grammar": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-io": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-9.3.2.tgz", + "integrity": "sha512-ahoULCp0j12TyXXmIcdO/7x65A/2mzUQO1IkOC65OXEbNT+evt0yswSO5Nr1F6kCHDuEKc46EZWwsYAzj78pMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-service-bus": "9.3.2", + "@cspell/url": "9.3.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-lib": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-9.3.2.tgz", + "integrity": "sha512-kdk11kib68zNANNICuOA8h4oA9kENQUAdeX/uvT4+7eHbHHV8WSgjXm4k4o/pRIbg164UJTX/XxKb/65ftn5jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-bundled-dicts": "9.3.2", + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-resolver": "9.3.2", + "@cspell/cspell-types": "9.3.2", + "@cspell/dynamic-import": "9.3.2", + "@cspell/filetypes": "9.3.2", + "@cspell/strong-weak-map": "9.3.2", + "@cspell/url": "9.3.2", + "clear-module": "^4.1.2", + "cspell-config-lib": "9.3.2", + "cspell-dictionary": "9.3.2", + "cspell-glob": "9.3.2", + "cspell-grammar": "9.3.2", + "cspell-io": "9.3.2", + "cspell-trie-lib": "9.3.2", + "env-paths": "^3.0.0", + "gensequence": "^8.0.8", + "import-fresh": "^3.3.1", + "resolve-from": "^5.0.0", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-trie-lib": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-9.3.2.tgz", + "integrity": "sha512-1Af7Mq9jIccFQyJl/ZCcqQbtJwuDqpQVkk8xfs/92x4OI6gW1iTVRMtsrh0RTw1HZoR8aQD7tRRCiLPf/D+UiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-types": "9.3.2", + "gensequence": "^8.0.8" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz", + "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/focus-trap": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz", + "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.3.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensequence": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-8.0.8.tgz", + "integrity": "sha512-omMVniXEXpdx/vKxGnPRoO2394Otlze28TyxECbFVyoSpZ9H3EO7lemjcB12OpQJzRW4e5tt/dL1rOxry6aMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/parent-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-2.0.0.tgz", + "integrity": "sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.27.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", + "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", + "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/smol-toml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz", + "integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", + "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-sfc": "3.5.24", + "@vue/runtime-dom": "3.5.24", + "@vue/server-renderer": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@iconify-json/simple-icons": { - "version": "1.2.59", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.59.tgz", - "integrity": "sha512-fYx/InyQsWFW4wVxWka3CGDJ6m/fXoTqWBSl+oA3FBXO5RhPAb6S3Y5bRgCPnrYevErH8VjAL0TZevIqlN2PhQ==", - "dev": true, - "license": "CC0-1.0", - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@shikijs/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", - "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", - "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^3.1.0" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", - "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", - "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/themes": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", - "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/transformers": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", - "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/types": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", - "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", - "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.24", - "entities": "^4.5.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz", - "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.24", - "@vue/shared": "3.5.24" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz", - "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.24", - "@vue/compiler-dom": "3.5.24", - "@vue/compiler-ssr": "3.5.24", - "@vue/shared": "3.5.24", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.6", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz", - "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.24", - "@vue/shared": "3.5.24" - } - }, - "node_modules/@vue/devtools-api": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^7.7.9" - } - }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-shared": "^7.7.9", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "rfdc": "^1.4.1" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", - "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.24" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", - "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.24", - "@vue/shared": "3.5.24" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", - "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.24", - "@vue/runtime-core": "3.5.24", - "@vue/shared": "3.5.24", - "csstype": "^3.1.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", - "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.24", - "@vue/shared": "3.5.24" - }, - "peerDependencies": { - "vue": "3.5.24" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", - "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vueuse/core": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", - "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/integrations": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", - "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vueuse/core": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "async-validator": "^4", - "axios": "^1", - "change-case": "^5", - "drauu": "^0.4", - "focus-trap": "^7", - "fuse.js": "^7", - "idb-keyval": "^6", - "jwt-decode": "^4", - "nprogress": "^0.2", - "qrcode": "^1.5", - "sortablejs": "^1", - "universal-cookie": "^7" - }, - "peerDependenciesMeta": { - "async-validator": { - "optional": true - }, - "axios": { - "optional": true - }, - "change-case": { - "optional": true - }, - "drauu": { - "optional": true - }, - "focus-trap": { - "optional": true - }, - "fuse.js": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "jwt-decode": { - "optional": true - }, - "nprogress": { - "optional": true - }, - "qrcode": { - "optional": true - }, - "sortablejs": { - "optional": true - }, - "universal-cookie": { - "optional": true - } - } - }, - "node_modules/@vueuse/metadata": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", - "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", - "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/algoliasearch": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.44.0.tgz", - "integrity": "sha512-f8IpsbdQjzTjr/4mJ/jv5UplrtyMnnciGax6/B0OnLCs2/GJTK13O4Y7Ff1AvJVAaztanH+m5nzPoUq6EAy+aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.10.0", - "@algolia/client-abtesting": "5.44.0", - "@algolia/client-analytics": "5.44.0", - "@algolia/client-common": "5.44.0", - "@algolia/client-insights": "5.44.0", - "@algolia/client-personalization": "5.44.0", - "@algolia/client-query-suggestions": "5.44.0", - "@algolia/client-search": "5.44.0", - "@algolia/ingestion": "1.44.0", - "@algolia/monitoring": "1.44.0", - "@algolia/recommend": "5.44.0", - "@algolia/requester-browser-xhr": "5.44.0", - "@algolia/requester-fetch": "5.44.0", - "@algolia/requester-node-http": "5.44.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/birpc": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz", - "integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk-template": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.2.tgz", - "integrity": "sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.2.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/chalk/chalk-template?sponsor=1" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/clear-module": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz", - "integrity": "sha512-LWAxzHqdHsAZlPlEyJ2Poz6AIs384mPeqLVCru2p0BrP9G/kVGuhNyZYClLO6cXlnuJjzC8xtsJIuMjKqLXoAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^2.0.0", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/comment-json": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.4.1.tgz", - "integrity": "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cspell": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cspell/-/cspell-9.3.2.tgz", - "integrity": "sha512-3xFyVSTYrYa/QJzLfzsCRMkMXqOsytP8E26DuGrVMJQoLPFmbOXNNtnMu4wrtr17QVloxpvutW77U4vb2L/LDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-json-reporter": "9.3.2", - "@cspell/cspell-pipe": "9.3.2", - "@cspell/cspell-types": "9.3.2", - "@cspell/dynamic-import": "9.3.2", - "@cspell/url": "9.3.2", - "chalk": "^5.6.2", - "chalk-template": "^1.1.2", - "commander": "^14.0.2", - "cspell-config-lib": "9.3.2", - "cspell-dictionary": "9.3.2", - "cspell-gitignore": "9.3.2", - "cspell-glob": "9.3.2", - "cspell-io": "9.3.2", - "cspell-lib": "9.3.2", - "fast-json-stable-stringify": "^2.1.0", - "flatted": "^3.3.3", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15" - }, - "bin": { - "cspell": "bin.mjs", - "cspell-esm": "bin.mjs" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/streetsidesoftware/cspell?sponsor=1" - } - }, - "node_modules/cspell-config-lib": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-9.3.2.tgz", - "integrity": "sha512-zXhmA4rqgWQRTVijI+g/mgiep76TvTO4d+P3CHwcqLG57BKVzoW+jkO4qDLC+Neh4b8+CcNWEIr3w16BfuEJAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-types": "9.3.2", - "comment-json": "^4.4.1", - "smol-toml": "^1.5.2", - "yaml": "^2.8.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cspell-dictionary": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-9.3.2.tgz", - "integrity": "sha512-E3YhOhZzZt1a+AEbFV2B3THCyZ576PDg0mDNUDrU1Y65SyIhf4DC6itfPoAb6R3FI/DI218RqWZg/FTT8lJ2gA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-pipe": "9.3.2", - "@cspell/cspell-types": "9.3.2", - "cspell-trie-lib": "9.3.2", - "fast-equals": "^5.3.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cspell-gitignore": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-9.3.2.tgz", - "integrity": "sha512-G2bLR+Dfb9GX4Sdm75GfCCa9V/sQYkRbLckuCuVmJxvcDB0xfczAtb6TfAXIziF3oUI6cOB1g+PoNLWBelcK5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/url": "9.3.2", - "cspell-glob": "9.3.2", - "cspell-io": "9.3.2" - }, - "bin": { - "cspell-gitignore": "bin.mjs" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cspell-glob": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-9.3.2.tgz", - "integrity": "sha512-TuSupENEKyOCupOUZ3vnPxaTOghxY/rD1JIkb8e5kjzRprYVilO/rYqEk/52iLwJVd+4Npe8fNhR3KhU7u/UUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/url": "9.3.2", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cspell-grammar": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-9.3.2.tgz", - "integrity": "sha512-ysonrFu9vJvF/derDlEjUfmvLeCfNOWPh00t6Yh093AKrJFoWQiyaS/5bEN/uB5/n1sa4k3ItnWvuTp3+YuZsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-pipe": "9.3.2", - "@cspell/cspell-types": "9.3.2" - }, - "bin": { - "cspell-grammar": "bin.mjs" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cspell-io": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-9.3.2.tgz", - "integrity": "sha512-ahoULCp0j12TyXXmIcdO/7x65A/2mzUQO1IkOC65OXEbNT+evt0yswSO5Nr1F6kCHDuEKc46EZWwsYAzj78pMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-service-bus": "9.3.2", - "@cspell/url": "9.3.2" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cspell-lib": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-9.3.2.tgz", - "integrity": "sha512-kdk11kib68zNANNICuOA8h4oA9kENQUAdeX/uvT4+7eHbHHV8WSgjXm4k4o/pRIbg164UJTX/XxKb/65ftn5jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-bundled-dicts": "9.3.2", - "@cspell/cspell-pipe": "9.3.2", - "@cspell/cspell-resolver": "9.3.2", - "@cspell/cspell-types": "9.3.2", - "@cspell/dynamic-import": "9.3.2", - "@cspell/filetypes": "9.3.2", - "@cspell/strong-weak-map": "9.3.2", - "@cspell/url": "9.3.2", - "clear-module": "^4.1.2", - "cspell-config-lib": "9.3.2", - "cspell-dictionary": "9.3.2", - "cspell-glob": "9.3.2", - "cspell-grammar": "9.3.2", - "cspell-io": "9.3.2", - "cspell-trie-lib": "9.3.2", - "env-paths": "^3.0.0", - "gensequence": "^8.0.8", - "import-fresh": "^3.3.1", - "resolve-from": "^5.0.0", - "vscode-languageserver-textdocument": "^1.0.12", - "vscode-uri": "^3.1.0", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cspell-trie-lib": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-9.3.2.tgz", - "integrity": "sha512-1Af7Mq9jIccFQyJl/ZCcqQbtJwuDqpQVkk8xfs/92x4OI6gW1iTVRMtsrh0RTw1HZoR8aQD7tRRCiLPf/D+UiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspell/cspell-pipe": "9.3.2", - "@cspell/cspell-types": "9.3.2", - "gensequence": "^8.0.8" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-equals": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz", - "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/focus-trap": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz", - "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tabbable": "^6.3.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensequence": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-8.0.8.tgz", - "integrity": "sha512-omMVniXEXpdx/vKxGnPRoO2394Otlze28TyxECbFVyoSpZ9H3EO7lemjcB12OpQJzRW4e5tt/dL1rOxry6aMHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/global-directory": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", - "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "4.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mark.js": { - "version": "8.11.1", - "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", - "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/minisearch": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", - "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", - "dev": true, - "license": "MIT" - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/oniguruma-to-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", - "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex-xs": "^1.0.0", - "regex": "^6.0.1", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/parent-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-2.0.0.tgz", - "integrity": "sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/preact": { - "version": "10.27.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", - "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", - "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shiki": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", - "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/langs": "2.5.0", - "@shikijs/themes": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/smol-toml": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", - "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/superjson": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz", - "integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tabbable": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", - "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vitepress": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/css": "3.8.2", - "@docsearch/js": "3.8.2", - "@iconify-json/simple-icons": "^1.2.21", - "@shikijs/core": "^2.1.0", - "@shikijs/transformers": "^2.1.0", - "@shikijs/types": "^2.1.0", - "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/devtools-api": "^7.7.0", - "@vue/shared": "^3.5.13", - "@vueuse/core": "^12.4.0", - "@vueuse/integrations": "^12.4.0", - "focus-trap": "^7.6.4", - "mark.js": "8.11.1", - "minisearch": "^7.1.1", - "shiki": "^2.1.0", - "vite": "^5.4.14", - "vue": "^3.5.13" - }, - "bin": { - "vitepress": "bin/vitepress.js" - }, - "peerDependencies": { - "markdown-it-mathjax3": "^4", - "postcss": "^8" - }, - "peerDependenciesMeta": { - "markdown-it-mathjax3": { - "optional": true - }, - "postcss": { - "optional": true - } - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "dev": true, - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vue": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", - "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.24", - "@vue/compiler-sfc": "3.5.24", - "@vue/runtime-dom": "3.5.24", - "@vue/server-renderer": "3.5.24", - "@vue/shared": "3.5.24" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..a669e690 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "vault-link", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..a9107050 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,11 @@ +# Rustfmt configuration +# This should match the .editorconfig settings + +# Use spaces for indentation (matches .editorconfig indent_style = space) +hard_tabs = false + +# Use 4 spaces for indentation (matches .editorconfig indent_size = 4) +tab_spaces = 4 + +# Use Unix line endings (matches .editorconfig end_of_line = lf) +newline_style = "Unix" diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index fb953e2a..bea3d982 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -35,7 +35,8 @@ cd .. cp frontend/obsidian-plugin/manifest.json manifest.json # for BRAT, otherwise it wouldn't update -git ls-files | xargs npx eclint fix +# Format all files across the project (frontend and backend) +npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}" # Commit and tag git add . diff --git a/scripts/check.sh b/scripts/check.sh index 7c3c87e5..2ee0dd62 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -30,8 +30,11 @@ fi which cargo-machete || cargo install cargo-machete cargo machete --with-metadata +cd .. +scripts/update-api-types.sh # this will dirty up the git state if not up-to-date + echo "Running checks in frontend" -cd ../frontend +cd frontend if [[ "$FIX_MODE" == true ]]; then npm install @@ -45,10 +48,11 @@ cd frontend npm run build npm run test npm run lint +cd .. -# Use git ls-files to only check tracked files, respecting .gitignore -# We always run in fix mode and then check with git status -git ls-files | xargs npx eclint fix +# Format all files across the project (frontend and backend) +# Prettier respects .gitignore by default +npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}" if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then git status --porcelain @@ -56,6 +60,4 @@ if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then exit 1 fi -cd .. - echo "Success" diff --git a/scripts/clean-up.sh b/scripts/clean-up.sh index 4dfbf4a0..dcf400bb 100755 --- a/scripts/clean-up.sh +++ b/scripts/clean-up.sh @@ -1,4 +1,4 @@ #!/bin/bash -rm -rf sync-server/databases +rm -rf /host/tmp/vaultlink-e2e-databases rm -rf logs diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 6c66e835..7ab8d90c 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -19,35 +19,51 @@ process_count=$1 mkdir -p logs +# Build and restart the server +echo "Building server..." +cd sync-server +cargo build --release + +# Kill any existing server process +echo "Stopping existing server..." +pkill -f "sync_server" 2>/dev/null || true +sleep 1 + +# Clean databases (uses tmpfs via /dev/shm for zero disk I/O) +echo "Cleaning databases..." +rm -rf /host/tmp/vaultlink-e2e-databases + +# Start the server in the background +echo "Starting server..." +./target/release/sync_server config-e2e.yml & +server_pid=$! +echo "Server started with PID: $server_pid" + +# Ensure server is killed on script exit +cleanup_server() { + if [ -n "$server_pid" ]; then + echo "Stopping server (PID: $server_pid)..." + kill $server_pid 2>/dev/null || true + wait $server_pid 2>/dev/null || true + server_pid="" + fi +} +trap cleanup_server EXIT + +cd .. + cd frontend npm ci npm run build ../scripts/utils/wait-for-server.sh -cd .. -scripts/update-api-types.sh -if [[ $(git status --porcelain) ]]; then - git status --porcelain - echo "Failing CI because the working directory is not clean after generating api types" - exit 1 -fi -cd frontend - pids=() for i in $(seq 1 $process_count); do - # Create a named pipe for this process - pipe="/tmp/vaultlink_pipe_$$_$i" - mkfifo "$pipe" - - # Start the node process writing to the pipe - node test-client/dist/cli.js > "$pipe" 2>&1 & + node test-client/dist/cli.js > "../logs/log_${i}.log" 2>&1 & pid=$! pids+=($pid) - echo "Started process $i with PID: $pid" - - # Read from pipe, prefix with PID - (sed "s/^/[PID $pid] /" < "$pipe" > "../logs/log_${i}.log"; rm "$pipe") & + echo "Started process $i with PID: $pid (log: logs/log_${i}.log)" done cd .. @@ -75,10 +91,25 @@ print_failed_log() { return 1 } -echo "Monitoring $process_count processes" +E2E_TIMEOUT=${2:-3600} +start_time=$(date +%s) +echo "Monitoring $process_count processes (timeout: ${E2E_TIMEOUT}s)" # Monitor processes while true; do + # Script-level timeout to prevent indefinite hangs + current_time=$(date +%s) + elapsed=$((current_time - start_time)) + if [ $elapsed -ge $E2E_TIMEOUT ]; then + echo "E2E timeout reached (${E2E_TIMEOUT}s). Killing remaining processes." + for pid in "${pids[@]}"; do + if [ -n "$pid" ]; then + kill $pid 2>/dev/null || true + fi + done + exit 1 + fi + if print_failed_log; then # Kill remaining processes for pid in "${pids[@]}"; do @@ -99,6 +130,7 @@ while true; do done if $all_done; then + cleanup_server echo "All processes completed successfully" exit 0 fi diff --git a/scripts/update-api-types.sh b/scripts/update-api-types.sh index 4b947ee8..3f4a9e2a 100755 --- a/scripts/update-api-types.sh +++ b/scripts/update-api-types.sh @@ -8,9 +8,15 @@ cd sync-server cargo test export_bindings cd - +# Both target directories contain only generated bindings — wipe and copy +rm -f frontend/sync-client/src/services/types/*.ts +rm -f frontend/history-ui/src/lib/types/*.ts cp -r sync-server/bindings/* frontend/sync-client/src/services/types/ +cp -r sync-server/bindings/* frontend/history-ui/src/lib/types/ cd frontend npm run lint -git ls-files | xargs npx eclint fix -cd - +cd .. + +# Format all files across the project (frontend and backend) +npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}" diff --git a/scripts/utils/check-node.sh b/scripts/utils/check-node.sh index c9ede47e..d93f2f27 100755 --- a/scripts/utils/check-node.sh +++ b/scripts/utils/check-node.sh @@ -2,8 +2,10 @@ set -e +TARGET_NODE_VERSION=25 + node_version=$(node -v | sed 's/^v\([0-9]*\).*/\1/') -if [ "$node_version" != "22" ]; then - echo "Error: This script requires Node.js version 22, found: $node_version" +if [ "$node_version" != "$TARGET_NODE_VERSION" ]; then + echo "Error: This script requires Node.js version $TARGET_NODE_VERSION, found: $node_version" exit 1 fi diff --git a/scripts/utils/wait-for-server.sh b/scripts/utils/wait-for-server.sh index 7824c405..71103477 100755 --- a/scripts/utils/wait-for-server.sh +++ b/scripts/utils/wait-for-server.sh @@ -2,14 +2,14 @@ set -e -SERVER_URL="http://localhost:3000" +SERVER_URL="http://localhost:3010" MAX_RETRIES=30 RETRY_INTERVAL_IN_SECONDS=5 echo "Waiting for $SERVER_URL to become available..." count=0 while [ $count -lt $MAX_RETRIES ]; do - if curl -s -f -o /dev/null $SERVER_URL; then + if curl -s -o /dev/null $SERVER_URL; then echo "$SERVER_URL is now available!" break fi From 0e3132f96c826eaa26f577f6f5e92c6d074505b3 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 10:15:21 +0100 Subject: [PATCH 14/19] Add deterministic-tests (#190) Reviewed-on: https://home.schmelczer.dev/git/git/andras/vault-link/pulls/190 Co-authored-by: Andras Schmelczer Co-committed-by: Andras Schmelczer --- frontend/deterministic-tests/README.md | 118 +++++ frontend/deterministic-tests/package.json | 23 + frontend/deterministic-tests/src/cli.ts | 243 +++++++++ frontend/deterministic-tests/src/consts.ts | 17 + .../src/deterministic-agent.ts | 483 ++++++++++++++++++ .../src/managed-websocket.ts | 245 +++++++++ .../deterministic-tests/src/parse-args.ts | 43 ++ .../src/prefixed-logger.ts | 28 + .../src/run-with-concurrency.ts | 33 ++ .../deterministic-tests/src/server-control.ts | 296 +++++++++++ .../deterministic-tests/src/server-manager.ts | 71 +++ .../src/test-definition.ts | 49 ++ .../deterministic-tests/src/test-registry.ts | 245 +++++++++ .../deterministic-tests/src/test-runner.ts | 399 +++++++++++++++ ...inary-pending-create-not-displaced.test.ts | 40 ++ .../tests/binary-to-text-transition.test.ts | 97 ++++ ...chup-create-and-update-not-skipped.test.ts | 66 +++ ...sce-update-remote-update-data-loss.test.ts | 59 +++ ...esced-remote-update-watermark-loss.test.ts | 53 ++ ...urrent-delete-during-remote-update.test.ts | 32 ++ ...oncurrent-edit-exact-same-position.test.ts | 49 ++ ...-and-create-at-target-create-first.test.ts | 49 ++ ...-and-create-at-target-rename-first.test.ts | 52 ++ .../concurrent-rename-first-wins.test.ts | 61 +++ .../concurrent-rename-same-target.test.ts | 39 ++ ...concurrent-update-diff-consistency.test.ts | 51 ++ .../src/tests/create-delete-noop.test.ts | 27 + .../create-during-reconciliation.test.ts | 50 ++ .../src/tests/create-merge-delete.test.ts | 37 ++ ...ate-merge-preserves-renamed-update.test.ts | 59 +++ .../create-rename-create-same-path.test.ts | 34 ++ .../create-rename-response-skips-file.test.ts | 36 ++ ...reate-update-coalesce-server-pause.test.ts | 32 ++ ...lete-by-other-client-then-recreate.test.ts | 40 ++ .../delete-during-pending-create.test.ts | 35 ++ .../delete-recreate-concurrent-update.test.ts | 42 ++ .../delete-recreate-different-content.test.ts | 54 ++ .../tests/delete-recreate-same-path.test.ts | 34 ++ ...-create-with-stale-deleting-record.test.ts | 52 ++ .../src/tests/delete-rename-conflict.test.ts | 43 ++ .../displaced-file-not-marked-deleted.test.ts | 38 ++ .../src/tests/double-offline-cycle.test.ts | 77 +++ .../idempotency-after-server-pause.test.ts | 33 ++ .../tests/interrupted-delete-retry.test.ts | 29 ++ ...ocal-edit-lost-during-create-merge.test.ts | 41 ++ ...ocal-rename-survives-remote-rename.test.ts | 80 +++ ...ocal-update-survives-remote-rename.test.ts | 69 +++ ...mc-cross-create-rename-same-target.test.ts | 46 ++ .../mc-delete-then-offline-rename.test.ts | 39 ++ .../mc-multi-delete-offline-rename.test.ts | 49 ++ ...three-client-rename-offline-update.test.ts | 41 ++ ...date-response-survives-user-rename.test.ts | 77 +++ .../move-and-concurrent-remote-update.test.ts | 43 ++ .../src/tests/move-chain-three-files.test.ts | 42 ++ .../move-identical-content-ambiguity.test.ts | 44 ++ .../move-preserves-remote-update.test.ts | 48 ++ .../move-remote-update-reverts-rename.test.ts | 38 ++ .../tests/move-then-delete-stale-path.test.ts | 34 ++ .../src/tests/multi-file-operations.test.ts | 45 ++ .../tests/offline-concurrent-renames.test.ts | 59 +++ ...offline-create-same-path-mergeable.test.ts | 41 ++ .../offline-delete-remote-rename.test.ts | 38 ++ .../offline-delete-vs-remote-update.test.ts | 46 ++ .../tests/offline-edit-remote-rename.test.ts | 49 ++ ...ffline-edit-then-move-same-content.test.ts | 51 ++ .../tests/offline-mixed-operations.test.ts | 57 +++ .../offline-move-then-remote-delete.test.ts | 36 ++ .../src/tests/offline-multiple-edits.test.ts | 40 ++ .../src/tests/offline-rename-and-edit.test.ts | 43 ++ ...line-rename-remote-create-old-path.test.ts | 51 ++ ...ffline-update-both-then-delete-one.test.ts | 75 +++ ...e-both-create-same-path-deconflict.test.ts | 34 ++ ...te-rename-concurrent-create-orphan.test.ts | 41 ++ ...date-while-other-creates-same-path.test.ts | 48 ++ ...online-delete-recreate-rapid-cycle.test.ts | 37 ++ .../online-edit-vs-delete-convergence.test.ts | 31 ++ .../overlapping-edits-same-section.test.ts | 54 ++ ...e-reset-loses-coalesced-local-edit.test.ts | 36 ++ ...delete-does-not-hijack-reused-path.test.ts | 56 ++ .../rapid-create-update-delete-cycle.test.ts | 52 ++ ...pid-edit-delete-online-convergence.test.ts | 48 ++ .../tests/rapid-updates-after-merge.test.ts | 49 ++ ...ently-deleted-cleared-on-reconnect.test.ts | 45 ++ ...e-quick-write-rename-before-record.test.ts | 36 ++ ...collides-with-pending-local-create.test.ts | 76 +++ ...mote-update-resurrects-deleted-doc.test.ts | 59 +++ ...remote-update-survives-user-rename.test.ts | 84 +++ ...rename-chain-during-pending-create.test.ts | 64 +++ .../tests/rename-chain-then-delete.test.ts | 50 ++ .../src/tests/rename-chain.test.ts | 34 ++ .../src/tests/rename-circular.test.ts | 44 ++ .../src/tests/rename-create-conflict.test.ts | 34 ++ ...rwrites-pending-create-then-delete.test.ts | 51 ++ ...ame-pending-create-before-response.test.ts | 42 ++ ...ng-create-onto-pending-delete-path.test.ts | 59 +++ .../src/tests/rename-roundtrip.test.ts | 40 ++ .../src/tests/rename-swap.test.ts | 44 ++ ...name-to-path-of-unconfirmed-delete.test.ts | 44 ++ .../rename-to-pending-path-fallback.test.ts | 43 ++ .../src/tests/rename-update-conflict.test.ts | 42 ++ ...ing-create-reused-path-then-delete.test.ts | 65 +++ ...ears-recently-deleted-resurrection.test.ts | 43 ++ ...ote-quick-write-and-pending-rename.test.ts | 82 +++ ...n-local-create-after-remote-create.test.ts | 121 +++++ ...nding-rename-aliases-second-create.test.ts | 152 ++++++ ...equential-create-duplicate-content.test.ts | 43 ++ .../server-pause-both-clients-create.test.ts | 42 ++ .../server-pause-both-edit-same-file.test.ts | 68 +++ .../server-pause-delete-recreate.test.ts | 38 ++ .../server-pause-rename-edit-resume.test.ts | 50 ++ .../server-pause-update-and-create.test.ts | 54 ++ ...multaneous-create-delete-same-path.test.ts | 38 ++ .../text-pending-create-not-displaced.test.ts | 36 ++ .../three-client-rename-create-delete.test.ts | 55 ++ ...ate-does-not-survive-remote-delete.test.ts | 36 ++ .../update-during-create-processing.test.ts | 42 ++ ...ser-parenthesized-file-not-deleted.test.ts | 47 ++ .../tests/watermark-advances-on-skip.test.ts | 35 ++ ...ark-gap-remote-update-not-recorded.test.ts | 37 ++ .../deterministic-tests/src/utils/assert.ts | 5 + .../src/utils/assertable-state.ts | 150 ++++++ .../src/utils/find-free-port.ts | 29 ++ .../deterministic-tests/src/utils/sleep.ts | 3 + .../src/utils/with-timeout.ts | 15 + frontend/deterministic-tests/tsconfig.json | 12 + .../deterministic-tests/webpack.config.js | 30 ++ frontend/package.json | 3 +- 127 files changed, 7722 insertions(+), 1 deletion(-) create mode 100644 frontend/deterministic-tests/README.md create mode 100644 frontend/deterministic-tests/package.json create mode 100644 frontend/deterministic-tests/src/cli.ts create mode 100644 frontend/deterministic-tests/src/consts.ts create mode 100644 frontend/deterministic-tests/src/deterministic-agent.ts create mode 100644 frontend/deterministic-tests/src/managed-websocket.ts create mode 100644 frontend/deterministic-tests/src/parse-args.ts create mode 100644 frontend/deterministic-tests/src/prefixed-logger.ts create mode 100644 frontend/deterministic-tests/src/run-with-concurrency.ts create mode 100644 frontend/deterministic-tests/src/server-control.ts create mode 100644 frontend/deterministic-tests/src/server-manager.ts create mode 100644 frontend/deterministic-tests/src/test-definition.ts create mode 100644 frontend/deterministic-tests/src/test-registry.ts create mode 100644 frontend/deterministic-tests/src/test-runner.ts create mode 100644 frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts create mode 100644 frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts create mode 100644 frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts create mode 100644 frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts create mode 100644 frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts create mode 100644 frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts create mode 100644 frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts create mode 100644 frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts create mode 100644 frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts create mode 100644 frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts create mode 100644 frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts create mode 100644 frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts create mode 100644 frontend/deterministic-tests/src/tests/create-delete-noop.test.ts create mode 100644 frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts create mode 100644 frontend/deterministic-tests/src/tests/create-merge-delete.test.ts create mode 100644 frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts create mode 100644 frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts create mode 100644 frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts create mode 100644 frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts create mode 100644 frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts create mode 100644 frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts create mode 100644 frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts create mode 100644 frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts create mode 100644 frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts create mode 100644 frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts create mode 100644 frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts create mode 100644 frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts create mode 100644 frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts create mode 100644 frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts create mode 100644 frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts create mode 100644 frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts create mode 100644 frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts create mode 100644 frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts create mode 100644 frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts create mode 100644 frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts create mode 100644 frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts create mode 100644 frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts create mode 100644 frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts create mode 100644 frontend/deterministic-tests/src/tests/multi-file-operations.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-create-same-path-mergeable.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts create mode 100644 frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts create mode 100644 frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts create mode 100644 frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts create mode 100644 frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts create mode 100644 frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts create mode 100644 frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts create mode 100644 frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts create mode 100644 frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts create mode 100644 frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts create mode 100644 frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts create mode 100644 frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts create mode 100644 frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts create mode 100644 frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts create mode 100644 frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-chain.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-circular.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-swap.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts create mode 100644 frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts create mode 100644 frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts create mode 100644 frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts create mode 100644 frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts create mode 100644 frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts create mode 100644 frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts create mode 100644 frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts create mode 100644 frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts create mode 100644 frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts create mode 100644 frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts create mode 100644 frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts create mode 100644 frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts create mode 100644 frontend/deterministic-tests/src/tests/text-pending-create-not-displaced.test.ts create mode 100644 frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts create mode 100644 frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts create mode 100644 frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts create mode 100644 frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts create mode 100644 frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts create mode 100644 frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts create mode 100644 frontend/deterministic-tests/src/utils/assert.ts create mode 100644 frontend/deterministic-tests/src/utils/assertable-state.ts create mode 100644 frontend/deterministic-tests/src/utils/find-free-port.ts create mode 100644 frontend/deterministic-tests/src/utils/sleep.ts create mode 100644 frontend/deterministic-tests/src/utils/with-timeout.ts create mode 100644 frontend/deterministic-tests/tsconfig.json create mode 100644 frontend/deterministic-tests/webpack.config.js diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md new file mode 100644 index 00000000..6fa2848c --- /dev/null +++ b/frontend/deterministic-tests/README.md @@ -0,0 +1,118 @@ +# Deterministic Tests + +Scripted multi-client (with an in-memory filesystem) sync tests that run against a real server. Each test defines a sequence of file operations, sync/server controls, and assertions to exercise a specific conflict or edge case. + +Complements the fuzz-based E2E tests (`test-client`): fuzz tests discover bugs through random operations; deterministic tests pin down exact reproduction sequences for known scenarios. + +## How it works + +Each test is a `TestDefinition`: a client count and an ordered list of steps. The test name is derived from the registry key (which matches the file name). The `TestRunner` spins up N `DeterministicAgent` instances (each wrapping a real `SyncClient` with an `InMemoryFileSystem`) pointed at a shared vault on the server, then executes steps one by one. + +Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process. + +The runner executes two sequential phases: regular tests on the shared server, then pause-server tests on dedicated servers. Within each phase tests run in parallel up to a concurrency limit. + +## Step types + +Clients always start with syncing disabled. + +**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited): + +- `create`, `update`, `rename`, `delete` +- `rename-next-write` — arm a deferred rename that fires the next time the given path is written. Lets a test race a user-rename against an in-flight remote create that's about to land at the same path. + +**Sync control:** + +- `sync` — wait for a specific client or all clients to finish pending operations +- `barrier` — retry until all clients converge to identical file state (60s timeout) +- `enable-sync` / `disable-sync` — simulate going online/offline +- `reset` — reset a client's tracked sync state (keeps disk files); equivalent to a forced re-handshake on next enable +- `sleep` — wall-clock pause; use sparingly, prefer `barrier` / `sync` + +**WebSocket control** (per-client): + +- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client + +**Server control:** + +- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process +- `resume-server-until-history-then-pause` — resume the server, wait until a specific client observes a matching history entry (`CREATE`/`UPDATE`/`DELETE` for a path), then re-pause. Used to land exactly one operation across the wire. + +**Fault injection** (per-client): + +- `drop-next-create-response` — arm a one-shot interceptor that lets the next `POST /documents` reach the server (commit happens) but throws `SyncResetError` before the client sees the response, simulating connection loss after server commit. +- `wait-for-dropped-create-response` — wait until the armed drop has fired. + +**Assertions:** + +- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback + +## Running + +```sh +# Build server first +cd sync-server && cargo build --release && cd - + +# Run all tests +cd frontend && npm run build -w sync-client && npm run test -w deterministic-tests + +# Filter by name +npm run test -w deterministic-tests -- --filter=rename + +# Control parallelism (default: number of CPU cores) +npm run test -w deterministic-tests -- -j 4 +``` + +## Adding a test + +1. Create `src/tests/my-scenario.test.ts`: + +```typescript +import type { TestDefinition } from "../test-definition"; + +export const myScenarioTest: TestDefinition = { + description: + "Client 0 creates A.md offline. After syncing, both clients should have the file.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "hello" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s) => { + s.assertFileCount(1).assertContent("A.md", "hello"); + } + } + ] +}; +``` + +The `verify` callback receives an `AssertableState` object with chainable assertion methods: + +```typescript +s.assertFileCount(n); // exact file count +s.assertFileExists("path"); // file must exist +s.assertFileNotExists("path"); // file must not exist +s.assertContent("path", "expected"); // exact content match +s.assertContains("path", "a", "b"); // all substrings present in file +s.assertContainsAny("path", "a", "b"); // at least one substring present +s.assertAnyFileContains("text"); // substring present in some file +s.assertNoFileContains("text"); // substring absent from every file +s.assertSubstringCount("path", "x", 3); // substring appears exactly N times +s.assertContentInAtMostOneFile("text"); // no duplicate content +s.ifFileExists("path", (s) => { /* … */ }); // conditional block +s.getContent("path"); // raw content (or "" if missing) +``` + +2. Register it in `src/test-registry.ts`: + +```typescript +import { myScenarioTest } from "./tests/my-scenario.test"; + +const TESTS = { + // ... + "my-scenario": myScenarioTest +}; +``` diff --git a/frontend/deterministic-tests/package.json b/frontend/deterministic-tests/package.json new file mode 100644 index 00000000..4bd82c74 --- /dev/null +++ b/frontend/deterministic-tests/package.json @@ -0,0 +1,23 @@ +{ + "name": "deterministic-tests", + "version": "0.14.0", + "private": true, + "bin": { + "deterministic-tests": "./dist/cli.js" + }, + "scripts": { + "dev": "webpack watch --mode development", + "build": "webpack --mode production", + "test": "npm run build && node dist/cli.js" + }, + "devDependencies": { + "commander": "^14.0.2", + "@types/node": "^25.0.2", + "sync-client": "file:../sync-client", + "ts-loader": "^9.5.4", + "tslib": "2.8.1", + "typescript": "5.9.3", + "webpack": "^5.103.0", + "webpack-cli": "^6.0.1" + } +} diff --git a/frontend/deterministic-tests/src/cli.ts b/frontend/deterministic-tests/src/cli.ts new file mode 100644 index 00000000..6e15cac0 --- /dev/null +++ b/frontend/deterministic-tests/src/cli.ts @@ -0,0 +1,243 @@ +import { TestRunner } from "./test-runner"; +import { ServerControl } from "./server-control"; +import { ServerManager } from "./server-manager"; +import { PrefixedLogger } from "./prefixed-logger"; +import { TESTS } from "./test-registry"; +import type { TestDefinition, TestResult } from "./test-definition"; +import { parseArgs } from "./parse-args"; +import { runWithConcurrency } from "./run-with-concurrency"; +import { TOKEN, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { debugging, Logger } from "sync-client"; + +const logger = new Logger(); +debugging.logToConsole(logger, { useColors: true }); + +process.on("unhandledRejection", (reason) => { + logger.error(`Unhandled Rejection: ${reason}`); + process.exit(1); +}); + +process.on("uncaughtException", (error) => { + logger.error(`Uncaught Exception: ${error}`); + process.exit(1); +}); + +const serverManager = new ServerManager(logger); +serverManager.installSignalHandlers(); + +function testUsesPauseServer(test: TestDefinition): boolean { + return test.steps.some( + (step) => + step.type === "pause-server" || + step.type === "resume-server" || + step.type === "resume-server-until-history-then-pause" + ); +} + +/** + * Walk up from the CLI binary's location until we find a directory + * containing `sync-server/` and `frontend/`. + */ +function findProjectRoot(): string { + let dir = path.dirname(__filename); + const root = path.parse(dir).root; + while (dir !== root) { + if ( + fs.existsSync(path.join(dir, "sync-server")) && + fs.existsSync(path.join(dir, "frontend")) + ) { + return dir; + } + dir = path.dirname(dir); + } + throw new Error( + `Could not locate project root (no ancestor of ${__filename} contains both 'sync-server' and 'frontend')` + ); +} + +interface NamedTestResult { + name: string; + result: TestResult; +} + +async function runSharedServerTest( + name: string, + test: TestDefinition, + sharedServer: ServerControl +): Promise { + const testLogger = new PrefixedLogger(logger, name); + const runner = new TestRunner( + sharedServer, + testLogger, + TOKEN, + sharedServer.remoteUri + ); + const result = await runner.runTest(name, test); + if (result.success) { + logger.info(`PASSED: ${name} (${result.duration}ms)`); + } else { + logger.error(`FAILED: ${name} - ${result.error}`); + } + return { name, result }; +} + +/** + * Run a test with its own dedicated server (for tests that use pause-server). + * SIGSTOP/SIGCONT affects the entire server process, so these tests need + * isolated servers to avoid interfering with other tests. + */ +async function runDedicatedServerTest( + name: string, + test: TestDefinition, + serverPath: string, + configPath: string +): Promise { + const testLogger = new PrefixedLogger(logger, name); + const server = new ServerControl(serverPath, configPath, testLogger); + serverManager.track(server); + + try { + await server.start(); + const runner = new TestRunner( + server, + testLogger, + TOKEN, + server.remoteUri + ); + const result = await runner.runTest(name, test); + if (result.success) { + logger.info(`PASSED: ${name} (${result.duration}ms)`); + } else { + logger.error(`FAILED: ${name} - ${result.error}`); + } + return { name, result }; + } finally { + try { + await server.stop(); + } catch { + // best-effort cleanup + } + serverManager.untrack(server); + } +} + +async function main(): Promise { + const projectRoot = findProjectRoot(); + const serverPath = path.join(projectRoot, SERVER_BINARY_PATH); + if (!fs.existsSync(serverPath)) { + logger.error(`Server binary not found at: ${serverPath}`); + process.exit(1); + } + + const configPath = path.join(projectRoot, CONFIG_PATH); + if (!fs.existsSync(configPath)) { + logger.error(`Config file not found at: ${configPath}`); + process.exit(1); + } + + const { filter, concurrency } = parseArgs(process.argv); + + const testsToRun: [string, TestDefinition][] = []; + for (const [key, test] of Object.entries(TESTS)) { + if (test) { + if ( + filter !== undefined && + filter.length > 0 && + !key.includes(filter) + ) { + continue; + } + testsToRun.push([key, test]); + } + } + + if (testsToRun.length === 0) { + logger.error( + filter !== undefined && filter.length > 0 + ? `No tests matched filter "${filter}"` + : "No tests found" + ); + process.exit(1); + } + + const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t)); + const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t)); + + logger.info(`Server: ${serverPath}`); + logger.info(`Config: ${configPath}`); + logger.info( + `Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)` + ); + logger.info(`Concurrency: ${concurrency}`); + + const allResults: NamedTestResult[] = []; + + if (regularTests.length > 0) { + logger.info( + `\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---` + ); + const sharedServer = new ServerControl(serverPath, configPath, logger); + serverManager.track(sharedServer); + + try { + await sharedServer.start(); + + const results = await runWithConcurrency( + regularTests, + concurrency, + async ([name, test]) => + runSharedServerTest(name, test, sharedServer) + ); + + allResults.push(...results); + } finally { + try { + await sharedServer.stop(); + } catch (error) { + logger.warn( + `Error stopping shared server: ${error instanceof Error ? error.message : String(error)}` + ); + } + serverManager.untrack(sharedServer); + } + } + + if (pauseTests.length > 0) { + logger.info( + `\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---` + ); + + const results = await runWithConcurrency( + pauseTests, + concurrency, + async ([name, test]) => + runDedicatedServerTest(name, test, serverPath, configPath) + ); + + allResults.push(...results); + } + + const passed = allResults.filter((r) => r.result.success); + const failed = allResults.filter((r) => !r.result.success); + + logger.info( + `\n--- Results: ${passed.length}/${allResults.length} passed ---` + ); + + if (failed.length > 0) { + for (const { name, result } of failed) { + logger.error(` FAILED: ${name}: ${result.error}`); + } + process.exit(1); + } else { + logger.info("All tests passed!"); + process.exit(0); + } +} + +main().catch((err: unknown) => { + logger.error(`Unexpected error: ${err}`); + process.exit(1); +}); diff --git a/frontend/deterministic-tests/src/consts.ts b/frontend/deterministic-tests/src/consts.ts new file mode 100644 index 00000000..d9a2498f --- /dev/null +++ b/frontend/deterministic-tests/src/consts.ts @@ -0,0 +1,17 @@ +export const TOKEN = "test-token-change-me"; +export const SERVER_BINARY_PATH = "sync-server/target/release/sync_server"; +export const CONFIG_PATH = "sync-server/config-e2e.yml"; + +export const STOP_TIMEOUT_MS = 5_000; +export const CONVERGENCE_TIMEOUT_MS = 60_000; +export const CONVERGENCE_RETRY_DELAY_MS = 500; +export const AGENT_INIT_TIMEOUT_MS = 30_000; +export const IS_SYNC_ENABLED_BY_DEFAULT = false; + +export const WAIT_TIMEOUT_MS = 60_000; +export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000; +export const WEBSOCKET_POLL_INTERVAL_MS = 50; + +export const SERVER_READY_POLL_INTERVAL_MS = 100; +export const SERVER_READY_MAX_ATTEMPTS = 50; +export const SERVER_START_MAX_ATTEMPTS = 5; diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts new file mode 100644 index 00000000..b32b01c2 --- /dev/null +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -0,0 +1,483 @@ +import type { + HistoryEntry, + StoredDatabase, + SyncSettings, + RelativePath, + TextWithCursors +} from "sync-client"; +import { + SyncClient, + SyncResetError, + debugging, + LogLevel, + utils +} from "sync-client"; +import { assert } from "./utils/assert"; +import { sleep } from "./utils/sleep"; +import { withTimeout } from "./utils/with-timeout"; +import { + IS_SYNC_ENABLED_BY_DEFAULT, + WAIT_TIMEOUT_MS, + WEBSOCKET_CONNECT_TIMEOUT_MS, + WEBSOCKET_POLL_INTERVAL_MS +} from "./consts"; +import { ManagedWebSocketFactory } from "./managed-websocket"; + +export class DeterministicAgent extends debugging.InMemoryFileSystem { + public readonly clientId: number; + private readonly logger: (msg: string) => void; + private client!: SyncClient; + private data: Partial<{ + settings: Partial; + database: Partial; + }> = {}; + private isSyncEnabled = IS_SYNC_ENABLED_BY_DEFAULT; + private readonly syncErrors: Error[] = []; + private readonly pendingSyncOperations = new Set>(); + private readonly wsFactory = new ManagedWebSocketFactory(); + private nextWriteRename: + | { + oldPath: RelativePath; + newPath: RelativePath; + } + | undefined; + private nextCreateResponseDrop: + | { + dropped: Promise; + resolveDropped: () => void; + } + | undefined; + + public constructor( + clientId: number, + initialSettings: Partial, + logger: (msg: string) => void + ) { + super(); + this.clientId = clientId; + this.logger = logger; + this.data.settings = { ...initialSettings }; + } + + public async init( + fetchImplementation: typeof globalThis.fetch + ): Promise { + this.client = await SyncClient.create({ + fs: this, + persistence: { + load: async () => this.data, + save: async (data) => void (this.data = data) + }, + fetch: this.wrapFetch(fetchImplementation), + webSocket: this.wsFactory.constructorFn + }); + + this.client.logger.onLogEmitted.add((line) => { + const prefix = `[Client ${this.clientId}]`; + switch (line.level) { + case LogLevel.ERROR: + this.logger(`${prefix} ERROR: ${line.message}`); + break; + case LogLevel.WARNING: + this.logger(`${prefix} WARN: ${line.message}`); + break; + case LogLevel.INFO: + this.logger(`${prefix} INFO: ${line.message}`); + break; + case LogLevel.DEBUG: + this.logger(`${prefix} DEBUG: ${line.message}`); + break; + } + }); + + await this.client.start(); + + const connectionCheck = await this.client.checkConnection(); + assert( + connectionCheck.isSuccessful, + `Client ${this.clientId} connection check failed` + ); + + if (this.isSyncEnabled) { + await this.waitForWebSocket(); + } + } + + public pauseWebSocket(): void { + this.log("Pausing WebSocket message delivery"); + this.wsFactory.pause(); + } + + public resumeWebSocket(): void { + this.log("Resuming WebSocket message delivery"); + this.wsFactory.resume(); + } + + public dropNextCreateResponse(): void { + assert( + this.nextCreateResponseDrop === undefined, + `Client ${this.clientId} already has a create response drop armed` + ); + let resolveDropped!: () => void; + const dropped = new Promise((resolve) => { + resolveDropped = resolve; + }); + this.nextCreateResponseDrop = { + dropped, + resolveDropped + }; + this.log("Armed next create response drop"); + } + + public async waitForDroppedCreateResponse(): Promise { + assert( + this.nextCreateResponseDrop !== undefined, + `Client ${this.clientId} has no create response drop armed` + ); + await withTimeout( + this.nextCreateResponseDrop.dropped, + WAIT_TIMEOUT_MS, + `Client ${this.clientId} timed out waiting for create response drop` + ); + this.log("Create response was dropped after server commit"); + } + + public async waitForHistoryEntry( + matches: (entry: HistoryEntry) => boolean, + onMatch?: (entry: HistoryEntry) => void + ): Promise { + const existing = this.client.getHistoryEntries().find(matches); + if (existing !== undefined) { + onMatch?.(existing); + return; + } + + await withTimeout( + new Promise((resolve) => { + const unsubscribe = this.client.onSyncHistoryUpdated.add(() => { + const entry = this.client + .getHistoryEntries() + .find(matches); + if (entry === undefined) { + return; + } + + unsubscribe(); + onMatch?.(entry); + resolve(); + }); + }), + WAIT_TIMEOUT_MS, + `Client ${this.clientId} timed out waiting for history entry` + ); + } + + public async waitForSync(): Promise { + this.log("Waiting for sync to complete..."); + // Drain agent-level sync operations first. These are the fire-and-forget + // promises from enqueueSync() that call into the SyncClient's methods. + // Without this, waitUntilFinished() might return before the SyncClient + // has even been told about the operation. + await this.drainPendingSyncOperations(); + await withTimeout( + this.client.waitUntilFinished(), + WAIT_TIMEOUT_MS, + `Client ${this.clientId} waitForSync timed out after ${WAIT_TIMEOUT_MS}ms` + ); + if (this.syncErrors.length > 0) { + const errors = this.syncErrors.splice(0); + throw new Error( + `Client ${this.clientId} had ${errors.length} sync error(s):\n${errors.map((e) => e.message).join("\n")}` + ); + } + this.log("Sync complete"); + } + + public async reset(): Promise { + this.log("Resetting client (clears tracked state, keeps disk files)"); + await this.drainPendingSyncOperations(); + await this.client.reset(); + if (this.isSyncEnabled) { + await this.waitForWebSocket(); + } + } + + public async disableSync(): Promise { + this.log("Disabling sync"); + // Drain pending enqueued operations before disabling so the SyncClient + // knows about all operations that were enqueued while sync was enabled. + await this.drainPendingSyncOperations(); + await this.client.setSetting("isSyncEnabled", false); + this.isSyncEnabled = false; + // Wait for in-flight operations to drain. Disabling sync triggers + // a reset, which aborts in-flight fetches with SyncResetError. + try { + await withTimeout( + this.client.waitUntilFinished(), + WAIT_TIMEOUT_MS, + `Client ${this.clientId} disableSync drain timed out` + ); + } catch (error) { + if (error instanceof Error && error.name === "SyncResetError") { + this.log("Disable sync drain interrupted by reset (expected)"); + } else { + throw error; + } + } + } + + public async enableSync(): Promise { + this.log("Enabling sync"); + await this.client.setSetting("isSyncEnabled", true); + this.isSyncEnabled = true; + await this.waitForWebSocket(); + } + + public async getFileContent(path: string): Promise { + const bytes = await this.read(path); + return new TextDecoder().decode(bytes); + } + + public renameNextWrite(oldPath: RelativePath, newPath: RelativePath): void { + assert( + this.nextWriteRename === undefined, + `Client ${this.clientId} already has a next-write rename armed` + ); + this.nextWriteRename = { oldPath, newPath }; + this.log(`Armed next write rename: ${oldPath} -> ${newPath}`); + } + + public async cleanup(): Promise { + this.log("Cleaning up..."); + // Guard against uninitialized client (init() failed partway). + // The class field uses `!:` so TS thinks this is always defined, + // but at runtime it can be undefined when init() throws partway. + const maybeClient = this.client as SyncClient | undefined; + if (maybeClient === undefined) { + this.log("Client not initialized, nothing to clean up"); + return; + } + try { + await this.drainPendingSyncOperations(); + await withTimeout( + this.client.waitUntilFinished(), + WAIT_TIMEOUT_MS, + `Client ${this.clientId} cleanup waitUntilFinished timed out` + ); + } catch (error) { + if (error instanceof Error && error.name === "SyncResetError") { + this.log(`Cleanup interrupted by reset (expected): ${error}`); + } else { + this.log(`Cleanup waitUntilFinished failed: ${error}`); + } + } + // Surface any background sync errors that arrived after the last + // waitForSync (e.g. between the final assert-consistent and here). + // Without this, regressions that fault the engine during the very + // last step of a test would be silently swallowed. + const pendingErrors = this.syncErrors.splice(0); + await this.client.destroy(); + this.log("Cleanup complete"); + if (pendingErrors.length > 0) { + throw new Error( + `Client ${this.clientId} had ${pendingErrors.length} background sync error(s) during cleanup:\n${pendingErrors.map((e) => e.message).join("\n")}` + ); + } + } + + public override async read(path: RelativePath): Promise { + await Promise.resolve(); + return super.read(path); + } + + public override async write( + path: RelativePath, + content: Uint8Array + ): Promise { + await Promise.resolve(); + const isNew = !this.files.has(path); + await super.write(path, content); + + if (this.isSyncEnabled && isNew) { + this.enqueueSync(async () => { + this.client.syncLocallyCreatedFile(path); + }); + } + + const nextWriteRename = this.nextWriteRename; + if ( + nextWriteRename !== undefined && + nextWriteRename.oldPath === path + ) { + this.nextWriteRename = undefined; + await super.rename( + nextWriteRename.oldPath, + nextWriteRename.newPath + ); + if (this.isSyncEnabled) { + this.enqueueSync(async () => { + this.client.syncLocallyUpdatedFile({ + oldPath: nextWriteRename.oldPath, + relativePath: nextWriteRename.newPath + }); + }); + } + // The rename consumed `path`. Skip the post-update enqueue below + // — it would send a syncLocallyUpdatedFile for a path that no + // longer exists. + return; + } + + if (!this.isSyncEnabled) { + return; + } + + if (!isNew) { + this.enqueueSync(async () => { + this.client.syncLocallyUpdatedFile({ relativePath: path }); + }); + } + } + + public override async atomicUpdateText( + path: RelativePath, + updater: (current: TextWithCursors) => TextWithCursors + ): Promise { + const result = await super.atomicUpdateText(path, updater); + if (this.isSyncEnabled) { + this.enqueueSync(async () => { + this.client.syncLocallyUpdatedFile({ relativePath: path }); + }); + } + return result; + } + + public override async delete(path: RelativePath): Promise { + await super.delete(path); + if (this.isSyncEnabled) { + this.enqueueSync(async () => { + this.client.syncLocallyDeletedFile(path); + }); + } + } + + public override async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + await super.rename(oldPath, newPath); + if (this.isSyncEnabled) { + this.enqueueSync(async () => { + this.client.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }); + }); + } + } + + private async waitForWebSocket(): Promise { + const deadline = Date.now() + WEBSOCKET_CONNECT_TIMEOUT_MS; + while (!this.client.isWebSocketConnected && Date.now() < deadline) { + await sleep(WEBSOCKET_POLL_INTERVAL_MS); + } + assert( + this.client.isWebSocketConnected, + `Client ${this.clientId} WebSocket failed to connect within ${WEBSOCKET_CONNECT_TIMEOUT_MS}ms` + ); + } + + /** + * Wait until all agent-level enqueued sync operations have completed. + * Uses a loop because completing one operation can trigger new enqueues. + */ + private async drainPendingSyncOperations(): Promise { + while (this.pendingSyncOperations.size > 0) { + await utils.awaitAll([...this.pendingSyncOperations]); + } + } + + private enqueueSync(operation: () => Promise): void { + const promise = this.executeSyncOperation(operation).catch( + (error: unknown) => { + const err = + error instanceof Error ? error : new Error(String(error)); + this.log(`Background sync failed: ${err.message}`); + this.syncErrors.push(err); + } + ); + this.pendingSyncOperations.add(promise); + void promise.finally(() => { + this.pendingSyncOperations.delete(promise); + }); + } + + private async executeSyncOperation( + operation: () => Promise + ): Promise { + try { + await operation(); + } catch (error) { + if (error instanceof Error && error.name === "SyncResetError") { + this.log(`Sync operation interrupted by reset: ${error}`); + return; + } + if ( + error instanceof Error && + error.message.includes("has been destroyed") + ) { + this.log(`Sync operation interrupted by destroy: ${error}`); + return; + } + + throw error; + } + } + + private log(message: string): void { + this.logger(`[Client ${this.clientId}] ${message}`); + } + + private wrapFetch( + fetchImplementation: typeof globalThis.fetch + ): typeof globalThis.fetch { + return async (input, init) => { + const response = await fetchImplementation(input, init); + const drop = this.nextCreateResponseDrop; + if ( + drop !== undefined && + DeterministicAgent.isCreateDocumentRequest(input, init) + ) { + this.nextCreateResponseDrop = undefined; + try { + await response.body?.cancel(); + } catch { + // Best-effort — body may already be consumed/closed. + } + drop.resolveDropped(); + throw new SyncResetError(); + } + return response; + }; + } + + private static isCreateDocumentRequest( + input: RequestInfo | URL, + init: RequestInit | undefined + ): boolean { + const method = + init?.method ?? + (typeof Request !== "undefined" && input instanceof Request + ? input.method + : "GET"); + if (method.toUpperCase() !== "POST") { + return false; + } + + const url = + input instanceof URL + ? input + : new URL(typeof input === "string" ? input : input.url); + return /\/documents\/?$/.test(url.pathname); + } +} diff --git a/frontend/deterministic-tests/src/managed-websocket.ts b/frontend/deterministic-tests/src/managed-websocket.ts new file mode 100644 index 00000000..c759891b --- /dev/null +++ b/frontend/deterministic-tests/src/managed-websocket.ts @@ -0,0 +1,245 @@ +/** + * A WebSocket wrapper that can pause and resume message delivery. + * When paused, incoming messages are buffered. When resumed, buffered + * messages are delivered in order via the onmessage handler. + * + * Member layout follows typescript-eslint default member-ordering: all + * accessor properties are declared with `declare` and wired through the + * constructor using Object.defineProperty so we don't need conflicting + * get/set accessor pairs. + */ +class ManagedWebSocket implements WebSocket { + public static readonly CONNECTING = WebSocket.CONNECTING; + public static readonly OPEN = WebSocket.OPEN; + public static readonly CLOSING = WebSocket.CLOSING; + public static readonly CLOSED = WebSocket.CLOSED; + + public readonly CONNECTING = WebSocket.CONNECTING; + public readonly OPEN = WebSocket.OPEN; + public readonly CLOSING = WebSocket.CLOSING; + public readonly CLOSED = WebSocket.CLOSED; + + declare public readonly readyState: number; + declare public readonly url: string; + declare public readonly protocol: string; + declare public readonly extensions: string; + declare public readonly bufferedAmount: number; + declare public binaryType: BinaryType; + declare public onopen: ((this: WebSocket, ev: Event) => unknown) | null; + declare public onclose: + | ((this: WebSocket, ev: CloseEvent) => unknown) + | null; + declare public onerror: ((this: WebSocket, ev: Event) => unknown) | null; + declare public onmessage: + | ((this: WebSocket, ev: MessageEvent) => unknown) + | null; + + private readonly ws: WebSocket; + private readonly bufferedMessages: MessageEvent[] = []; + private paused = false; + private externalOnMessage: ((event: MessageEvent) => unknown) | null = null; + + public constructor(url: string | URL, protocols?: string | string[]) { + this.ws = new WebSocket(url, protocols); + + const { ws } = this; + Object.defineProperties(this, { + readyState: { + get: (): number => ws.readyState, + enumerable: true, + configurable: true + }, + url: { + get: (): string => ws.url, + enumerable: true, + configurable: true + }, + protocol: { + get: (): string => ws.protocol, + enumerable: true, + configurable: true + }, + extensions: { + get: (): string => ws.extensions, + enumerable: true, + configurable: true + }, + bufferedAmount: { + get: (): number => ws.bufferedAmount, + enumerable: true, + configurable: true + }, + binaryType: { + get: (): BinaryType => ws.binaryType, + set: (v: BinaryType): void => { + ws.binaryType = v; + }, + enumerable: true, + configurable: true + }, + onopen: { + get: (): ((this: WebSocket, ev: Event) => unknown) | null => + ws.onopen, + set: ( + h: ((this: WebSocket, ev: Event) => unknown) | null + ): void => { + ws.onopen = h; + }, + enumerable: true, + configurable: true + }, + onclose: { + get: (): + | ((this: WebSocket, ev: CloseEvent) => unknown) + | null => ws.onclose, + set: ( + h: ((this: WebSocket, ev: CloseEvent) => unknown) | null + ): void => { + ws.onclose = h; + }, + enumerable: true, + configurable: true + }, + onerror: { + get: (): ((this: WebSocket, ev: Event) => unknown) | null => + ws.onerror, + set: ( + h: ((this: WebSocket, ev: Event) => unknown) | null + ): void => { + ws.onerror = h; + }, + enumerable: true, + configurable: true + }, + onmessage: { + get: (): + | ((this: WebSocket, ev: MessageEvent) => unknown) + | null => this.externalOnMessage, + set: ( + h: ((this: WebSocket, ev: MessageEvent) => unknown) | null + ): void => { + this.externalOnMessage = h; + }, + enumerable: true, + configurable: true + } + }); + + this.ws.onmessage = (event: MessageEvent): void => { + if (this.paused) { + this.bufferedMessages.push(event); + } else { + this.externalOnMessage?.(event); + } + }; + } + + public pause(): void { + this.paused = true; + } + + public resume(): void { + // Drain buffered messages BEFORE flipping `paused` to false. + // If `externalOnMessage` is async (its return type is `unknown`), + // dispatch yields control between buffered messages, and a fresh + // live `ws.onmessage` event firing during that yield would jump + // ahead of unprocessed buffered messages — silently reordering + // events relative to the wire. Keeping `paused = true` during the + // drain forces the live handler to keep buffering, so we splice + // those late arrivals onto the tail and dispatch them in order. + while (this.bufferedMessages.length > 0) { + const messages = this.bufferedMessages.splice(0); + for (const msg of messages) { + this.externalOnMessage?.(msg); + } + } + this.paused = false; + } + + public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + this.ws.send(data); + } + + public close(code?: number, reason?: string): void { + this.ws.close(code, reason); + } + + public addEventListener( + ...args: Parameters + ): void { + // Only the `.onmessage` setter routes through the pause buffer. + // If sync-client ever attaches "message" listeners via + // addEventListener instead, those messages would bypass pause/resume + // and deterministic tests would silently lose their fault injection. + if (args[0] === "message") { + throw new Error( + "ManagedWebSocket: addEventListener('message') bypasses the " + + "pause buffer. Use the .onmessage setter instead, or " + + "extend ManagedWebSocket to route message listeners." + ); + } + this.ws.addEventListener(...args); + } + + public removeEventListener( + ...args: Parameters + ): void { + this.ws.removeEventListener(...args); + } + + public dispatchEvent(event: Event): boolean { + return this.ws.dispatchEvent(event); + } +} + +/** + * Factory that creates ManagedWebSocket instances and tracks them + * for pause/resume control from the test harness + */ +export class ManagedWebSocketFactory { + // Append-only: closed sockets stay tracked. Bounded per test (one + // factory per agent, each test discards its agents on cleanup), so + // not a real leak — but iterating over closed instances on + // pause/resume is a deliberate no-op since their `.onmessage` is + // already detached. + private readonly instances: ManagedWebSocket[] = []; + // Sticky pause state: applied to current instances on `pause()` AND + // to any new instance created later (e.g. WS reconnect after a + // `disable-sync` / `reset` cycle). Without this, a test pausing the + // WS before the agent reconnects would silently see the new socket + // start un-paused and miss the messages it meant to buffer. + private currentlyPaused = false; + + public get constructorFn(): typeof globalThis.WebSocket { + const trackInstance = (instance: ManagedWebSocket): void => { + this.instances.push(instance); + if (this.currentlyPaused) { + instance.pause(); + } + }; + class TrackedManagedWebSocket extends ManagedWebSocket { + public constructor( + url: string | URL, + protocols?: string | string[] + ) { + super(url, protocols); + trackInstance(this); + } + } + return TrackedManagedWebSocket; + } + + public pause(): void { + this.currentlyPaused = true; + for (const ws of this.instances) { + ws.pause(); + } + } + + public resume(): void { + this.currentlyPaused = false; + for (const ws of this.instances) { + ws.resume(); + } + } +} diff --git a/frontend/deterministic-tests/src/parse-args.ts b/frontend/deterministic-tests/src/parse-args.ts new file mode 100644 index 00000000..11c56f19 --- /dev/null +++ b/frontend/deterministic-tests/src/parse-args.ts @@ -0,0 +1,43 @@ +import * as os from "node:os"; +import { Command, InvalidArgumentError } from "commander"; + +export interface CliArgs { + filter: string | undefined; + concurrency: number; +} + +function parsePositiveInt(value: string): number { + const n = parseInt(value, 10); + if (isNaN(n) || n <= 0) { + throw new InvalidArgumentError("must be a positive integer"); + } + return n; +} + +export function parseArgs(argv: string[]): CliArgs { + const program = new Command(); + + program + .name("deterministic-tests") + .description("Scripted multi-client sync tests against a real server") + .option( + "-f, --filter ", + "Run only tests whose name contains this substring" + ) + .option( + "-j, --concurrency ", + "Number of tests to run in parallel", + parsePositiveInt, + os.cpus().length + ); + + program.parse(argv); + + /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ + const opts = program.opts(); + const filter = opts.filter as string | undefined; + const concurrency = opts.concurrency as number; + /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ + + return { filter, concurrency }; +} diff --git a/frontend/deterministic-tests/src/prefixed-logger.ts b/frontend/deterministic-tests/src/prefixed-logger.ts new file mode 100644 index 00000000..769d7545 --- /dev/null +++ b/frontend/deterministic-tests/src/prefixed-logger.ts @@ -0,0 +1,28 @@ +import { Logger } from "sync-client"; + +export class PrefixedLogger extends Logger { + private readonly base: Logger; + private readonly prefix: string; + + public constructor(base: Logger, prefix: string) { + super(); + this.base = base; + this.prefix = prefix; + } + + public override debug(message: string): void { + this.base.debug(`[${this.prefix}] ${message}`); + } + + public override info(message: string): void { + this.base.info(`[${this.prefix}] ${message}`); + } + + public override warn(message: string): void { + this.base.warn(`[${this.prefix}] ${message}`); + } + + public override error(message: string): void { + this.base.error(`[${this.prefix}] ${message}`); + } +} diff --git a/frontend/deterministic-tests/src/run-with-concurrency.ts b/frontend/deterministic-tests/src/run-with-concurrency.ts new file mode 100644 index 00000000..f5bcf745 --- /dev/null +++ b/frontend/deterministic-tests/src/run-with-concurrency.ts @@ -0,0 +1,33 @@ +export async function runWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = []; + const errors: unknown[] = []; + const executing = new Set>(); + + for (let i = 0; i < items.length; i++) { + const index = i; + const p = fn(items[index]) + .then((result) => { + results[index] = result; + }) + .catch((error: unknown) => { + errors.push(error); + }) + .finally(() => executing.delete(p)); + executing.add(p); + if (executing.size >= concurrency) { + await Promise.race(executing); + } + } + + // eslint-disable-next-line no-restricted-properties + await Promise.all(executing); + + if (errors.length > 0) { + throw errors[0]; + } + return results; +} diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts new file mode 100644 index 00000000..9cb4cde0 --- /dev/null +++ b/frontend/deterministic-tests/src/server-control.ts @@ -0,0 +1,296 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { sleep } from "./utils/sleep"; +import { findFreePort } from "./utils/find-free-port"; +import type { Logger } from "sync-client"; +import { + STOP_TIMEOUT_MS, + SERVER_READY_POLL_INTERVAL_MS, + SERVER_READY_MAX_ATTEMPTS, + SERVER_START_MAX_ATTEMPTS +} from "./consts"; + +export class ServerControl { + private process: ChildProcess | null = null; + private readonly serverPath: string; + private readonly baseConfigPath: string; + private readonly logger: Logger; + private _port: number | undefined; + private tempDir: string | undefined; + private _isPaused = false; + + public constructor(serverPath: string, configPath: string, logger: Logger) { + this.serverPath = serverPath; + this.baseConfigPath = configPath; + this.logger = logger; + } + + public get port(): number { + if (this._port === undefined) { + throw new Error("Server has not been started yet"); + } + return this._port; + } + + public get remoteUri(): string { + return `http://localhost:${this.port}`; + } + + public async start(): Promise { + if (this.process !== null) { + throw new Error("Server is already running"); + } + + // Retry on bind failure: findFreePort closes its probe before we + // spawn, so under heavy parallelism another process can grab the + // same port. Each attempt picks a fresh port. + let lastError: unknown; + for (let attempt = 1; attempt <= SERVER_START_MAX_ATTEMPTS; attempt++) { + try { + await this.startOnce(); + return; + } catch (error) { + lastError = error; + this.logger.warn( + `Server start attempt ${attempt}/${SERVER_START_MAX_ATTEMPTS} failed: ${error instanceof Error ? error.message : String(error)}` + ); + // startOnce already cleaned up its child + tempdir on failure. + } + } + throw new Error( + `Server failed to start after ${SERVER_START_MAX_ATTEMPTS} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + { cause: lastError instanceof Error ? lastError : undefined } + ); + } + + private async startOnce(): Promise { + const reservation = await findFreePort(); + this._port = reservation.port; + const tmpBase = os.tmpdir(); + this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-")); + const tempConfigPath = path.join(this.tempDir, "config.yml"); + const dbDir = path.join(this.tempDir, "databases"); + + this.writeConfigFile(tempConfigPath, dbDir); + + this.logger.info( + `Starting server: ${this.serverPath} (port ${this._port})` + ); + + // Release the port reservation right before spawning to minimize + // the TOCTOU window between port discovery and server binding. + reservation.release(); + + this.process = spawn(this.serverPath, [tempConfigPath], { + stdio: ["ignore", "pipe", "pipe"], + detached: false + }); + + this.process.stdout?.on("data", (data: Buffer) => { + this.logger.info(`[SERVER] ${data.toString().trim()}`); + }); + + this.process.stderr?.on("data", (data: Buffer) => { + this.logger.info(`[SERVER] ${data.toString().trim()}`); + }); + + this.process.on("error", (err) => { + this.logger.error(`[SERVER] Process error: ${err.message}`); + }); + + const currentProcess = this.process; + currentProcess.on("exit", (code, signal) => { + this.logger.info( + `Server exited with code ${code}, signal ${signal}` + ); + // Only clear state if this handler is for the current process. + // A fast stop→start cycle could create a new process before this + // handler fires — clearing state here would corrupt the new one. + if (this.process === currentProcess) { + this.process = null; + this._isPaused = false; + } + }); + + try { + await this.waitForReady(); + } catch (error) { + // Kill the spawned process if it failed to become ready, + // preventing a zombie process from lingering. + try { + await this.stop(); + } catch { + // Best-effort cleanup + } + throw error; + } + } + + public async waitForReady( + maxAttempts: number = SERVER_READY_MAX_ATTEMPTS + ): Promise { + const pingUrl = `${this.remoteUri}/vaults/test/ping`; + for (let i = 0; i < maxAttempts; i++) { + if (this.process?.exitCode !== null) { + throw new Error( + "Server process died while waiting for it to become ready" + ); + } + try { + const response = await fetch(pingUrl); + if (response.ok) { + this.logger.info("[SERVER] Ready"); + return; + } + } catch { + // Server not ready yet, continue polling + } + await sleep(SERVER_READY_POLL_INTERVAL_MS); + } + throw new Error("Server failed to start within timeout"); + } + + public pause(): void { + if (this.process?.pid === undefined) { + throw new Error("Server is not running"); + } + if (this._isPaused) { + this.logger.warn("Server is already paused, skipping double-pause"); + return; + } + this.logger.info("Server pausing..."); + try { + process.kill(this.process.pid, "SIGSTOP"); + this._isPaused = true; + this.logger.info("Server paused (SIGSTOP sent)"); + } catch (error) { + throw new Error( + `Failed to pause server (pid ${this.process.pid}): ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + public resume(): void { + if (this.process?.pid === undefined) { + throw new Error("Server is not running"); + } + if (!this._isPaused) { + return; + } + this.logger.info("Server resuming..."); + try { + process.kill(this.process.pid, "SIGCONT"); + this._isPaused = false; + this.logger.info("Server resumed (SIGCONT sent)"); + } catch (error) { + throw new Error( + `Failed to resume server (pid ${this.process.pid}): ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + public async stop(): Promise { + const proc = this.process; + if (proc?.pid === undefined) { + this.cleanupTempDir(); + return; + } + + // Resume if paused — a SIGSTOP'd process ignores SIGKILL + if (this._isPaused) { + try { + process.kill(proc.pid, "SIGCONT"); + } catch { + // Process may already be gone + } + this._isPaused = false; + } + + this.logger.info("Server stopping..."); + + // Set up a promise that resolves when the process actually exits. + const exitPromise = new Promise((resolve) => { + if (proc.exitCode !== null) { + resolve(); + return; + } + proc.on("exit", () => { + resolve(); + }); + }); + + try { + process.kill(proc.pid, "SIGKILL"); + } catch { + // Process already gone + } + + // Wait for the process to actually exit before cleaning up, + // with a 5s safety timeout to avoid hanging forever. + await Promise.race([exitPromise, sleep(STOP_TIMEOUT_MS)]); + + this.process = null; + this._isPaused = false; + this.cleanupTempDir(); + } + + public isRunning(): boolean { + const proc = this.process; + return ( + proc !== null && + proc.pid !== undefined && + proc.exitCode === null && + proc.signalCode === null + ); + } + + /** + * Synchronously SIGCONT-then-SIGKILL the child process. Safe to call + * from a `process.on("exit", ...)` handler, where async work cannot + * run. Used as a last-resort cleanup so a SIGSTOP'd server doesn't + * outlive the test runner and wedge the next CI invocation. + */ + public forceKillSync(): void { + const proc = this.process; + if (proc?.pid === undefined) { + return; + } + try { + process.kill(proc.pid, "SIGCONT"); + } catch { + // Process may already be gone or never paused. + } + try { + process.kill(proc.pid, "SIGKILL"); + } catch { + // Process already gone. + } + } + + private writeConfigFile(destPath: string, dbDir: string): void { + // Assumes config-e2e.yml has exactly one 2-space-indented `port:` and + // one `databases_directory_path:` (under `server:` and `database:` + // respectively) + const baseConfig = fs.readFileSync(this.baseConfigPath, "utf-8"); + const config = baseConfig + .replace(/^\s*port:\s*\d+/m, ` port: ${this._port}`) + .replace( + /^\s*databases_directory_path:\s*.+/m, + ` databases_directory_path: ${dbDir}` + ); + fs.writeFileSync(destPath, config); + } + + private cleanupTempDir(): void { + if (this.tempDir !== undefined) { + try { + fs.rmSync(this.tempDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup + } + this.tempDir = undefined; + } + } +} diff --git a/frontend/deterministic-tests/src/server-manager.ts b/frontend/deterministic-tests/src/server-manager.ts new file mode 100644 index 00000000..76c624f7 --- /dev/null +++ b/frontend/deterministic-tests/src/server-manager.ts @@ -0,0 +1,71 @@ +import type { ServerControl } from "./server-control"; +import type { Logger } from "sync-client"; + +export class ServerManager { + private readonly activeServers = new Set(); + private readonly logger: Logger; + private isShuttingDown = false; + + public constructor(logger: Logger) { + this.logger = logger; + } + + public track(server: ServerControl): void { + this.activeServers.add(server); + } + + public untrack(server: ServerControl): void { + this.activeServers.delete(server); + } + + public async stopAll(): Promise { + if (this.isShuttingDown) { + return; + } + this.isShuttingDown = true; + + const servers = Array.from(this.activeServers); + // eslint-disable-next-line no-restricted-properties + await Promise.all( + servers.map(async (server) => { + try { + await server.stop(); + } catch { + // Best-effort cleanup during shutdown + } + }) + ); + } + + public installSignalHandlers(): void { + process.on("SIGINT", () => { + this.logger.info("Received SIGINT, shutting down..."); + void this.stopAll() + .catch(() => { + /* no-op */ + }) + .then(() => process.exit(130)); + }); + + process.on("SIGTERM", () => { + this.logger.info("Received SIGTERM, shutting down..."); + void this.stopAll() + .catch(() => { + /* no-op */ + }) + .then(() => process.exit(143)); + }); + + // Last-resort synchronous cleanup. Runs even when the process is + // exiting via process.exit() from unhandledRejection / + // uncaughtException — paths where async stopAll() cannot complete. + // SIGSTOP'd servers MUST receive SIGCONT before SIGKILL or the + // kernel keeps them as zombies holding the test's tmpdir, and the + // next CI run can't reuse the port. + process.on("exit", () => { + for (const server of this.activeServers) { + server.forceKillSync(); + } + }); + } +} diff --git a/frontend/deterministic-tests/src/test-definition.ts b/frontend/deterministic-tests/src/test-definition.ts new file mode 100644 index 00000000..bd832a50 --- /dev/null +++ b/frontend/deterministic-tests/src/test-definition.ts @@ -0,0 +1,49 @@ +import type { AssertableState } from "./utils/assertable-state"; + +export interface ClientState { + files: Map; + clientFiles: Map[]; +} + +export type TestStep = + | { type: "create"; client: number; path: string; content: string } + | { type: "update"; client: number; path: string; content: string } + | { type: "rename"; client: number; oldPath: string; newPath: string } + | { + type: "rename-next-write"; + client: number; + oldPath: string; + newPath: string; + } + | { type: "delete"; client: number; path: string } + | { type: "sync"; client?: number } + | { type: "disable-sync"; client: number } + | { type: "enable-sync"; client: number } + | { type: "pause-server" } + | { type: "resume-server" } + | { + type: "resume-server-until-history-then-pause"; + client: number; + syncType: "CREATE" | "UPDATE" | "DELETE"; + path: string; + } + | { type: "barrier" } + | { type: "assert-consistent"; verify?: (state: AssertableState) => void } + | { type: "pause-websocket"; client: number } + | { type: "resume-websocket"; client: number } + | { type: "drop-next-create-response"; client: number } + | { type: "wait-for-dropped-create-response"; client: number } + | { type: "sleep"; ms: number } + | { type: "reset"; client: number }; + +export interface TestDefinition { + description?: string; + clients: number; + steps: TestStep[]; +} + +export interface TestResult { + success: boolean; + error?: string; + duration?: number; +} diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts new file mode 100644 index 00000000..1a07b411 --- /dev/null +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -0,0 +1,245 @@ +import type { TestDefinition } from "./test-definition"; +import { renameCreateConflictTest } from "./tests/rename-create-conflict.test"; +import { renameChainTest } from "./tests/rename-chain.test"; +import { renameUpdateConflictTest } from "./tests/rename-update-conflict.test"; +import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.test"; +import { multiFileOperationsTest } from "./tests/multi-file-operations.test"; +import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test"; +import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test"; +import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test"; +import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test"; +import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test"; +import { mcThreeClientRenameOfflineUpdateTest } from "./tests/mc-three-client-rename-offline-update.test"; +import { mcMultiDeleteOfflineRenameTest } from "./tests/mc-multi-delete-offline-rename.test"; +import { mcCrossCreateRenameSameTargetTest } from "./tests/mc-cross-create-rename-same-target.test"; +import { mcDeleteThenOfflineRenameTest } from "./tests/mc-delete-then-offline-rename.test"; +import { offlineMixedOperationsTest } from "./tests/offline-mixed-operations.test"; +import { offlineConcurrentRenamesTest } from "./tests/offline-concurrent-renames.test"; +import { offlineMultipleEditsTest } from "./tests/offline-multiple-edits.test"; +import { serverPauseBothClientsCreateTest } from "./tests/server-pause-both-clients-create.test"; +import { serverPauseUpdateAndCreateTest } from "./tests/server-pause-update-and-create.test"; +import { renameSwapTest } from "./tests/rename-swap.test"; +import { renameCircularTest } from "./tests/rename-circular.test"; +import { renameRoundtripTest } from "./tests/rename-roundtrip.test"; +import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-remote-create-old-path.test"; +import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test"; +import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test"; +import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test"; +import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test"; +import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test"; +import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test"; +import { moveAndConcurrentRemoteUpdateTest } from "./tests/move-and-concurrent-remote-update.test"; +import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remote-update.test"; +import { doubleOfflineCycleTest } from "./tests/double-offline-cycle.test"; +import { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test"; +import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.test"; +import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-mergeable.test"; +import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test"; +import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test"; +import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test"; +import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test"; +import { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test"; +import { serverPauseBothEditSameFileTest } from "./tests/server-pause-both-edit-same-file.test"; +import { deleteRecreateDifferentContentTest } from "./tests/delete-recreate-different-content.test"; +import { updateDuringCreateProcessingTest } from "./tests/update-during-create-processing.test"; +import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remote-delete.test"; +import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test"; +import { moveThenDeleteStalePathTest } from "./tests/move-then-delete-stale-path.test"; +import { interruptedDeleteRetryTest } from "./tests/interrupted-delete-retry.test"; +import { updateDoesNotSurviveRemoteDeleteTest } from "./tests/update-does-not-survive-remote-delete.test"; +import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test"; +import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test"; +import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test"; +import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test"; +import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test"; +import { renameToPendingPathFallbackTest } from "./tests/rename-to-pending-path-fallback.test"; +import { moveRemoteUpdateRevertsRenameTest } from "./tests/move-remote-update-reverts-rename.test"; +import { localEditLostDuringCreateMergeTest } from "./tests/local-edit-lost-during-create-merge.test"; +import { renamePendingCreateBeforeResponseTest } from "./tests/rename-pending-create-before-response.test"; +import { createRenameResponseSkipsFileTest } from "./tests/create-rename-response-skips-file.test"; +import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test"; +import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test"; +import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test"; +import { textPendingCreateNotDisplacedTest } from "./tests/text-pending-create-not-displaced.test"; +import { binaryPendingCreateNotDisplacedTest } from "./tests/binary-pending-create-not-displaced.test"; +import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/coalesce-update-remote-update-data-loss.test"; +import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/coalesced-remote-update-watermark-loss.test"; +import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/concurrent-delete-during-remote-update.test"; +import { concurrentEditExactSamePositionTest } from "./tests/concurrent-edit-exact-same-position.test"; +import { concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/concurrent-rename-and-create-at-target-rename-first.test"; +import { concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/concurrent-rename-and-create-at-target-create-first.test"; +import { concurrentRenameSameTargetTest } from "./tests/concurrent-rename-same-target.test"; +import { concurrentUpdateDiffConsistencyTest } from "./tests/concurrent-update-diff-consistency.test"; +import { userParenthesizedFileNotDeletedTest } from "./tests/user-parenthesized-file-not-deleted.test"; +import { createDeleteNoopTest } from "./tests/create-delete-noop.test"; +import { createMergeDeleteTest } from "./tests/create-merge-delete.test"; +import { moveIdenticalContentAmbiguityTest } from "./tests/move-identical-content-ambiguity.test"; +import { createUpdateCoalesceServerPauseTest } from "./tests/create-update-coalesce-server-pause.test"; +import { createDuringReconciliationTest } from "./tests/create-during-reconciliation.test"; +import { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.test"; +import { createRenameCreateSamePathTest } from "./tests/create-rename-create-same-path.test"; +import { moveChainThreeFilesTest } from "./tests/move-chain-three-files.test"; +import { deleteByOtherClientThenRecreateTest } from "./tests/delete-by-other-client-then-recreate.test"; +import { onlineDeleteRecreateRapidCycleTest } from "./tests/online-delete-recreate-rapid-cycle.test"; +import { onlineEditVsDeleteConvergenceTest } from "./tests/online-edit-vs-delete-convergence.test"; +import { rapidEditDeleteOnlineConvergenceTest } from "./tests/rapid-edit-delete-online-convergence.test"; +import { serverPauseDeleteRecreateTest } from "./tests/server-pause-delete-recreate.test"; +import { onlineBothCreateSamePathDeconflictTest } from "./tests/online-both-create-same-path-deconflict.test"; +import { onlineCreateUpdateWhileOtherCreatesSamePathTest } from "./tests/online-create-update-while-other-creates-same-path.test"; +import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-marked-deleted.test"; +import { remoteUpdateResurrectsDeletedDocTest } from "./tests/remote-update-resurrects-deleted-doc.test"; +import { localUpdateSurvivesRemoteRenameTest } from "./tests/local-update-survives-remote-rename.test"; +import { mergingUpdateResponseSurvivesUserRenameTest } from "./tests/merging-update-response-survives-user-rename.test"; +import { catchupCreateAndUpdateNotSkippedTest } from "./tests/catchup-create-and-update-not-skipped.test"; +import { localRenameSurvivesRemoteRenameTest } from "./tests/local-rename-survives-remote-rename.test"; +import { renameChainDuringPendingCreateTest } from "./tests/rename-chain-during-pending-create.test"; +import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test"; +import { remoteUpdateSurvivesUserRenameTest } from "./tests/remote-update-survives-user-rename.test"; +import { sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest } from "./tests/same-doc-id-collapse-on-local-create-after-remote-create.test"; +import { sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest } from "./tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test"; +import { renameOverwritesPendingCreateThenDeleteTest } from "./tests/rename-overwrites-pending-create-then-delete.test"; +import { deleteRecreatedPendingCreateWithStaleDeletingRecordTest } from "./tests/delete-recreated-pending-create-with-stale-deleting-record.test"; +import { queuedCreateDeleteDoesNotHijackReusedPathTest } from "./tests/queued-create-delete-does-not-hijack-reused-path.test"; +import { renamedPendingCreateReusedPathThenDeleteTest } from "./tests/renamed-pending-create-reused-path-then-delete.test"; +import { renamePendingCreateOntoPendingDeletePathTest } from "./tests/rename-pending-create-onto-pending-delete-path.test"; +import { remoteQuickWriteRenameBeforeRecordTest } from "./tests/remote-quick-write-rename-before-record.test"; +import { selfMergePendingRenameAliasesSecondCreateTest } from "./tests/self-merge-pending-rename-aliases-second-create.test"; + +export const TESTS: Partial> = { + "rename-create-conflict": renameCreateConflictTest, + "rename-chain": renameChainTest, + "rename-update-conflict": renameUpdateConflictTest, + "delete-rename-conflict": deleteRenameConflictTest, + "multi-file-operations": multiFileOperationsTest, + "delete-recreate-same-path": deleteRecreateSamePathTest, + "offline-rename-and-edit": offlineRenameAndEditTest, + "simultaneous-create-delete-same-path": + simultaneousCreateDeleteSamePathTest, + "idempotency-after-server-pause": idempotencyAfterServerPauseTest, + "sequential-create-duplicate-content": sequentialCreateDuplicateContentTest, + "mc-three-client-rename-offline-update": + mcThreeClientRenameOfflineUpdateTest, + "mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest, + "mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest, + "mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest, + "offline-mixed-operations": offlineMixedOperationsTest, + "offline-concurrent-renames": offlineConcurrentRenamesTest, + "offline-multiple-edits": offlineMultipleEditsTest, + "server-pause-both-clients-create": serverPauseBothClientsCreateTest, + "server-pause-update-and-create": serverPauseUpdateAndCreateTest, + "rename-swap": renameSwapTest, + "rename-circular": renameCircularTest, + "rename-roundtrip": renameRoundtripTest, + "offline-rename-remote-create-old-path": + offlineRenameRemoteCreateOldPathTest, + "offline-edit-remote-rename": offlineEditRemoteRenameTest, + "rename-chain-then-delete": renameChainThenDeleteTest, + "offline-delete-remote-rename": offlineDeleteRemoteRenameTest, + "overlapping-edits-same-section": overlappingEditsSameSectionTest, + "rapid-updates-after-merge": rapidUpdatesAfterMergeTest, + "delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest, + "move-and-concurrent-remote-update": moveAndConcurrentRemoteUpdateTest, + "double-offline-cycle": doubleOfflineCycleTest, + "server-pause-rename-edit-resume": serverPauseRenameEditResumeTest, + "offline-update-both-then-delete-one": offlineUpdateBothThenDeleteOneTest, + "offline-create-same-path-mergeable": offlineCreateSamePathMergeableTest, + "delete-during-pending-create": deleteDuringPendingCreateTest, + "three-client-rename-create-delete": threeClientRenameCreateDeleteTest, + "rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest, + "offline-edit-then-move-same-content": offlineEditThenMoveSameContentTest, + "rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest, + "server-pause-both-edit-same-file": serverPauseBothEditSameFileTest, + "delete-recreate-different-content": deleteRecreateDifferentContentTest, + "update-during-create-processing": updateDuringCreateProcessingTest, + "offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest, + "reset-clears-recently-deleted-resurrection": + resetClearsRecentlyDeletedResurrectionTest, + "move-then-delete-stale-path": moveThenDeleteStalePathTest, + "offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest, + "interrupted-delete-retry": interruptedDeleteRetryTest, + "update-does-not-survive-remote-delete": updateDoesNotSurviveRemoteDeleteTest, + "move-preserves-remote-update": movePreservesRemoteUpdateTest, + "recently-deleted-cleared-on-reconnect": + recentlyDeletedClearedOnReconnectTest, + "watermark-advances-on-skip": watermarkAdvancesOnSkipTest, + "watermark-gap-remote-update-not-recorded": + watermarkGapRemoteUpdateNotRecordedTest, + "queue-reset-loses-coalesced-local-edit": + queueResetLosesCoalescedLocalEditTest, + "rename-to-pending-path-fallback": renameToPendingPathFallbackTest, + "move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest, + "local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest, + "rename-pending-create-before-response": + renamePendingCreateBeforeResponseTest, + "create-rename-response-skips-file": createRenameResponseSkipsFileTest, + "online-create-rename-concurrent-create-orphan": + onlineCreateRenameConcurrentCreateOrphanTest, + "concurrent-rename-first-wins": concurrentRenameFirstWinsTest, + "binary-to-text-transition": binaryToTextTransitionTest, + "text-pending-create-not-displaced": textPendingCreateNotDisplacedTest, + "binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest, + "coalesce-update-remote-update-data-loss": + coalesceUpdateRemoteUpdateDataLossTest, + "coalesced-remote-update-watermark-loss": + coalescedRemoteUpdateWatermarkLossTest, + "concurrent-delete-during-remote-update": + concurrentDeleteDuringRemoteUpdateTest, + "concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest, + "concurrent-rename-and-create-at-target-rename-first": + concurrentRenameAndCreateAtTargetRenameFirstTest, + "concurrent-rename-and-create-at-target-create-first": + concurrentRenameAndCreateAtTargetCreateFirstTest, + "concurrent-rename-same-target": concurrentRenameSameTargetTest, + "concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest, + "user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest, + "create-delete-noop": createDeleteNoopTest, + "create-merge-delete": createMergeDeleteTest, + "move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest, + "create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest, + "create-during-reconciliation": createDuringReconciliationTest, + "create-merge-preserves-renamed-update": + createMergePreservesRenamedUpdateTest, + "create-rename-create-same-path": createRenameCreateSamePathTest, + "move-chain-three-files": moveChainThreeFilesTest, + "delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest, + "online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest, + "online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest, + "rapid-edit-delete-online-convergence": + rapidEditDeleteOnlineConvergenceTest, + "server-pause-delete-recreate": serverPauseDeleteRecreateTest, + "online-both-create-same-path-deconflict": + onlineBothCreateSamePathDeconflictTest, + "online-create-update-while-other-creates-same-path": + onlineCreateUpdateWhileOtherCreatesSamePathTest, + "displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest, + "remote-update-resurrects-deleted-doc": + remoteUpdateResurrectsDeletedDocTest, + "local-update-survives-remote-rename": localUpdateSurvivesRemoteRenameTest, + "merging-update-response-survives-user-rename": + mergingUpdateResponseSurvivesUserRenameTest, + "catchup-create-and-update-not-skipped": + catchupCreateAndUpdateNotSkippedTest, + "local-rename-survives-remote-rename": localRenameSurvivesRemoteRenameTest, + "rename-chain-during-pending-create": renameChainDuringPendingCreateTest, + "remote-rename-collides-with-pending-local-create": + remoteRenameCollidesWithPendingLocalCreateTest, + "remote-update-survives-user-rename": remoteUpdateSurvivesUserRenameTest, + "same-doc-id-collapse-on-local-create-after-remote-create": + sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest, + "renamed-pending-create-reused-path-then-delete": + renamedPendingCreateReusedPathThenDeleteTest, + "rename-pending-create-onto-pending-delete-path": + renamePendingCreateOntoPendingDeletePathTest, + "rename-overwrites-pending-create-then-delete": + renameOverwritesPendingCreateThenDeleteTest, + "same-doc-id-collapse-after-remote-quick-write-and-pending-rename": + sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest, + "delete-recreated-pending-create-with-stale-deleting-record": + deleteRecreatedPendingCreateWithStaleDeletingRecordTest, + "queued-create-delete-does-not-hijack-reused-path": + queuedCreateDeleteDoesNotHijackReusedPathTest, + "remote-quick-write-rename-before-record": + remoteQuickWriteRenameBeforeRecordTest, + "self-merge-pending-rename-aliases-second-create": + selfMergePendingRenameAliasesSecondCreateTest +}; diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts new file mode 100644 index 00000000..411e9b08 --- /dev/null +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -0,0 +1,399 @@ +import type { TestDefinition, TestResult, TestStep } from "./test-definition"; +import { DeterministicAgent } from "./deterministic-agent"; +import type { ServerControl } from "./server-control"; +import type { SyncSettings, Logger } from "sync-client"; +import { assert } from "./utils/assert"; +import { AssertableState } from "./utils/assertable-state"; +import { sleep } from "./utils/sleep"; +import { withTimeout } from "./utils/with-timeout"; +import { + CONVERGENCE_TIMEOUT_MS, + CONVERGENCE_RETRY_DELAY_MS, + AGENT_INIT_TIMEOUT_MS, + IS_SYNC_ENABLED_BY_DEFAULT +} from "./consts"; +import { randomUUID } from "node:crypto"; + +export class TestRunner { + private agents: DeterministicAgent[] = []; + private readonly serverControl: ServerControl; + private readonly token: string; + private readonly remoteUri: string; + private readonly logger: Logger; + + public constructor( + serverControl: ServerControl, + logger: Logger, + token: string, + remoteUri: string + ) { + this.serverControl = serverControl; + this.logger = logger; + this.token = token; + this.remoteUri = remoteUri; + } + + public async runTest( + name: string, + test: TestDefinition + ): Promise { + const startTime = Date.now(); + this.logger.info(`Running test: ${name}`); + if (test.description !== undefined && test.description !== "") { + this.logger.info(`Description: ${test.description}`); + } + this.logger.info(`Clients: ${test.clients}`); + this.logger.info(`Steps: ${test.steps.length}`); + + try { + assert( + this.serverControl.isRunning(), + "Server is not running before test start" + ); + + await this.initializeAgents(test.clients); + + for (let i = 0; i < test.steps.length; i++) { + const step = test.steps[i]; + this.logger.info( + `Step ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}` + ); + await this.executeStep(step); + } + + await this.cleanup(); + + const duration = Date.now() - startTime; + this.logger.info(`\n✓ Test passed: ${name} (${duration}ms)`); + + return { + success: true, + duration + }; + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.info(`\n✗ Test failed: ${name}`); + this.logger.info(`Error: ${errorMessage}`); + + await this.cleanup(); + + return { + success: false, + error: errorMessage, + duration + }; + } + } + + private async initializeAgents(count: number): Promise { + assert(count > 0, `Client count must be positive, got ${count}`); + const vaultName = `test-${randomUUID()}`; + this.logger.info( + `Initializing ${count} agents with vault: ${vaultName}` + ); + + for (let i = 0; i < count; i++) { + const settings: Partial = { + isSyncEnabled: IS_SYNC_ENABLED_BY_DEFAULT, + token: this.token, + vaultName, + remoteUri: this.remoteUri + }; + + const agent = new DeterministicAgent(i, settings, (msg) => { + this.logger.info(msg); + }); + + // Push before init so cleanup() handles this agent if init fails + this.agents.push(agent); + await withTimeout( + agent.init(fetch), + AGENT_INIT_TIMEOUT_MS, + `Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms` + ); + this.logger.info(`Initialized client ${i}`); + } + + this.logger.info("All agents initialized"); + } + + private getAgent(index: number): DeterministicAgent { + assert( + index >= 0 && index < this.agents.length, + `Client index ${index} out of bounds (have ${this.agents.length} agents)` + ); + return this.agents[index]; + } + + private async executeStep(step: TestStep): Promise { + switch (step.type) { + case "create": + case "update": + await this.getAgent(step.client).write( + step.path, + new TextEncoder().encode(step.content) + ); + break; + + case "rename": + await this.getAgent(step.client).rename( + step.oldPath, + step.newPath + ); + break; + + case "rename-next-write": + this.getAgent(step.client).renameNextWrite( + step.oldPath, + step.newPath + ); + break; + + case "delete": + await this.getAgent(step.client).delete(step.path); + break; + + case "sync": + if (step.client !== undefined) { + await this.getAgent(step.client).waitForSync(); + } else { + for (const agent of this.agents) { + await agent.waitForSync(); + } + } + break; + + case "disable-sync": + await this.getAgent(step.client).disableSync(); + break; + + case "enable-sync": + await this.getAgent(step.client).enableSync(); + break; + + case "pause-server": + this.serverControl.pause(); + break; + + case "resume-server": + this.serverControl.resume(); + // Verify the server is actually responsive before proceeding. + // This replaces relying solely on hardcoded waits. + await this.serverControl.waitForReady(); + break; + + case "resume-server-until-history-then-pause": { + const agent = this.getAgent(step.client); + const historySeen = agent.waitForHistoryEntry( + (entry) => + entry.details.type === step.syncType && + entry.details.relativePath === step.path, + () => this.serverControl.pause() + ); + this.serverControl.resume(); + await historySeen; + break; + } + + case "barrier": + await this.waitForConvergence(); + break; + + case "assert-consistent": + await this.assertConsistent(step.verify); + break; + + case "pause-websocket": + this.getAgent(step.client).pauseWebSocket(); + break; + + case "resume-websocket": + this.getAgent(step.client).resumeWebSocket(); + break; + + case "drop-next-create-response": + this.getAgent(step.client).dropNextCreateResponse(); + break; + + case "wait-for-dropped-create-response": + await this.getAgent(step.client).waitForDroppedCreateResponse(); + break; + + case "sleep": + await sleep(step.ms); + break; + + case "reset": + await this.getAgent(step.client).reset(); + break; + + default: { + const unknownStep = step as { type: string }; + throw new Error(`Unknown step type: ${unknownStep.type}`); + } + } + } + + /** + * Wait for all agents to reach a consistent state. + * + * Waiting for agents is done in two full rounds: the first round + * drains in-flight operations, but completing those operations can + * trigger new work on OTHER agents via server broadcasts. The second + * round waits for that cascading work to settle. Deeper cascades + * are handled by the outer retry loop. + */ + private async waitForConvergence(): Promise { + this.logger.info("Barrier: waiting for convergence..."); + + const deadline = Date.now() + CONVERGENCE_TIMEOUT_MS; + let lastError: Error | undefined = undefined; + + while (Date.now() < deadline) { + await this.waitAllAgentsSettled(); + + try { + await this.assertConsistent(); + this.logger.info("Barrier complete: all clients converged"); + return; + } catch (error) { + lastError = + error instanceof Error ? error : new Error(String(error)); + this.logger.info("Barrier: not yet converged, retrying..."); + await sleep(CONVERGENCE_RETRY_DELAY_MS); + } + } + + throw new Error( + `Convergence timed out after ${CONVERGENCE_TIMEOUT_MS}ms: ${lastError?.message ?? "no consistency check ran"}`, + { cause: lastError } + ); + } + + /** + * Wait for all agents to be simultaneously idle. + * + * Completing work on agent A can trigger a server broadcast that + * enqueues new work on agent B, which can cascade further. With N + * agents the worst-case cascade depth is N (a chain A→B→C→…→A), + * so we run N+1 sequential passes to drain it. Extra passes are + * essentially free when there is no outstanding work. + * + * The outer {@link waitForConvergence} loop with consistency checks + * remains the ultimate guarantee — this method just minimizes how + * many slow retry iterations are needed. + */ + private async waitAllAgentsSettled(): Promise { + const rounds = this.agents.length + 1; + for (let round = 0; round < rounds; round++) { + for (const agent of this.agents) { + await agent.waitForSync(); + } + } + } + + private async assertConsistent( + verify?: (state: AssertableState) => void + ): Promise { + this.logger.info("Asserting all clients are consistent..."); + assert( + this.agents.length >= 2, + "Need at least 2 agents for consistency check" + ); + + // Snapshot all agents' file states upfront to minimize the window + // where background sync could mutate state between reads. + const clientFiles: Map[] = []; + for (const agent of this.agents) { + const sortedFiles = (await agent.listFilesRecursively()).sort(); + const fileMap = new Map(); + for (const file of sortedFiles) { + const content = await agent.getFileContent(file); + fileMap.set(file, content); + } + clientFiles.push(fileMap); + } + + const referenceFiles = Array.from(clientFiles[0].keys()); + + this.logger.info( + `Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}` + ); + + for (let i = 1; i < clientFiles.length; i++) { + const agentFileKeys = Array.from(clientFiles[i].keys()); + + this.logger.info( + `Client ${i} has ${agentFileKeys.length} files: ${agentFileKeys.join(", ")}` + ); + + assert( + agentFileKeys.length === referenceFiles.length, + `File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${agentFileKeys.length} files` + ); + + for (let j = 0; j < agentFileKeys.length; j++) { + assert( + agentFileKeys[j] === referenceFiles[j], + `File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${agentFileKeys[j]}"` + ); + } + + for (const file of referenceFiles) { + const referenceContent = clientFiles[0].get(file); + const agentContent = clientFiles[i].get(file); + + assert( + referenceContent === agentContent, + `Content mismatch for ${file}:\nClient 0: "${referenceContent}"\nClient ${i}: "${agentContent}"` + ); + } + } + + this.logger.info("✓ All clients are consistent"); + + if (verify) { + this.logger.info("Running custom verification..."); + try { + verify( + new AssertableState({ + files: clientFiles[0], + clientFiles + }) + ); + } catch (error) { + const msg = + error instanceof Error ? error.message : String(error); + throw new Error(`Custom verification failed: ${msg}`); + } + this.logger.info("✓ Custom verification passed"); + } + } + + private async cleanup(): Promise { + // Always resume the server in case a test paused it and then + // failed before reaching the resume step. Without this, all + // subsequent tests would hang because the server process is + // frozen (SIGSTOP) and can't respond to HTTP or WebSocket. + try { + this.serverControl.resume(); + } catch { + // Server wasn't paused or isn't running — safe to ignore + } + + this.logger.info("\nCleaning up agents..."); + for (const agent of this.agents) { + try { + await agent.cleanup(); + } catch (error) { + this.logger.warn( + `Agent cleanup error: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + this.agents = []; + this.logger.info("Cleanup complete"); + } +} diff --git a/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts new file mode 100644 index 00000000..467c19f0 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts @@ -0,0 +1,40 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const binaryPendingCreateNotDisplacedTest: TestDefinition = { + description: + "Two clients each create a binary file at the same path while offline. " + + "After syncing, both files should exist on both clients at separate paths.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "data.bin", + content: "binary data from client 0" + }, + { + type: "create", + client: 1, + path: "data.bin", + content: "binary data from client 1" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(2) + .assertFileExists("data.bin") + .assertFileExists("data (1).bin") + .assertAnyFileContains( + "binary data from client 0", + "binary data from client 1" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts new file mode 100644 index 00000000..8b934c1b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts @@ -0,0 +1,97 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const binaryToTextTransitionTest: TestDefinition = { + description: + "A .bin file is created and synced. Both clients edit it offline " + + "(binary last-write-wins), then client 0 renames it to .md and " + + "writes a clean text baseline. Both clients edit different sections " + + "offline. The text merge should preserve both edits.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "data.bin", + content: "original content" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("data.bin", "original content"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "update", client: 0, path: "data.bin", content: "version A" }, + { type: "update", client: 1, path: "data.bin", content: "version B" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContainsAny( + "data.bin", + "version A", + "version B" + ); + } + }, + + { type: "disable-sync", client: 1 }, + { type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" }, + { + type: "update", + client: 0, + path: "data.md", + content: "top line\nmiddle line\nbottom line" + }, + { type: "sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent( + "data.md", + "top line\nmiddle line\nbottom line" + ); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { + type: "update", + client: 0, + path: "data.md", + content: "alpha\nmiddle line\nbottom line" + }, + { + type: "update", + client: 1, + path: "data.md", + content: "top line\nmiddle line\nbeta" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains("data.md", "alpha", "beta"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts b/frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts new file mode 100644 index 00000000..2d40228f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts @@ -0,0 +1,66 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const catchupCreateAndUpdateNotSkippedTest: TestDefinition = { + description: + "Client 1 disconnects (sync disabled). Client 0 creates a doc and " + + "then updates it. When Client 1 reconnects, the server's catch-up " + + "stream sends only the doc's *latest* version (the update), not the " + + "full history. Pre-fix the wire's `is_new_file` was set to " + + "`creation == latest_version`, so the catch-up flagged the doc as " + + "non-new even though Client 1 had never seen its creation. Client " + + "1's `processRemoteChange` then dropped it as a 'stale RemoteChange " + + "for untracked, non-new document' and the doc was silently lost. " + + "Post-fix `is_new_file` in the catch-up stream means 'new relative " + + "to the recipient's watermark' (`creation > last_seen_vault_update_id`).", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + // Establish a baseline so Client 1's last_seen is non-zero before + // we take it offline. This makes the bug genuinely about catch-up + // missing the create rather than just an empty-vault first sync. + { type: "create", client: 0, path: "warmup.md", content: "w\n" }, + { type: "barrier" }, + + // Client 1 goes offline. + { type: "disable-sync", client: 1 }, + + // Client 0 creates the doc (vault_update_id v_C, after Client 1's + // watermark). Client 1 doesn't see this because it's offline. + { type: "create", client: 0, path: "doc.md", content: "v1\n" }, + // Wait for the create's HTTP to land before the update; otherwise + // both writes are coalesced into a single POST and the server + // never sees the doc as "create followed by update". + { type: "sync", client: 0 }, + + // Client 0 updates the doc (vault_update_id v_X > v_C). The + // server's `latest_document_versions` view now returns the + // *update* row — its `creation_vault_update_id != vault_update_id`. + { + type: "update", + client: 0, + path: "doc.md", + content: "v1\nupdate\n" + }, + { type: "sync", client: 0 }, + + // Client 1 reconnects. Server's catch-up replays docs with + // `vault_update_id > last_seen`. For doc.md it sends v_X with + // `is_new_file` derived from `creation_vault_update_id > + // last_seen_vault_update_id` (post-fix) — so Client 1 treats it + // as a fresh create and downloads the latest content. + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(2); + state.assertFileExists("doc.md"); + state.assertContent("doc.md", "v1\nupdate\n"); + state.assertContent("warmup.md", "w\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts b/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts new file mode 100644 index 00000000..1972526a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts @@ -0,0 +1,59 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = { + description: + "Divergent offline edits with text-merge expectation. Client 0's " + + "remote update fully lands before Client 1 reconnects (`sync`-after " + + "the c0 update enforces this), so Client 1's offline edit merges " + + "against a server-known version, not a coalesced batch. Both " + + "additions must survive in the final merged content. (Filename's " + + "'coalesce' framing is aspirational — a true update-coalesce test " + + "would skip the c0 sync and queue overlapping local + remote " + + "updates against the same parent version.)", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "line 1\nline 2\nline 3" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { + type: "update", + client: 0, + path: "doc.md", + content: "line 1\nline 2\nline 3\nclient 0 addition" + }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 1, + path: "doc.md", + content: "client 1 addition\nline 1\nline 2\nline 3" + }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContains( + "doc.md", + "client 0 addition", + "client 1 addition" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts b/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts new file mode 100644 index 00000000..aceb8baa --- /dev/null +++ b/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts @@ -0,0 +1,53 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { + description: + "Client 0 sends three rapid updates. After syncing, both clients " + + "disconnect and reconnect twice. Content should remain correct " + + "after each reconnect.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "update", client: 0, path: "doc.md", content: "update 1" }, + { type: "update", client: 0, path: "doc.md", content: "update 2" }, + { type: "update", client: 0, path: "doc.md", content: "final update" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "final update"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "final update"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "final update"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts b/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts new file mode 100644 index 00000000..88376f22 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts @@ -0,0 +1,32 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = { + description: + "One client updates a file while the other deletes it at the same " + + "time. Both clients should converge without errors.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "update", client: 0, path: "doc.md", content: "updated by 0" }, + { type: "delete", client: 1, path: "doc.md" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts b/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts new file mode 100644 index 00000000..5c141a0e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts @@ -0,0 +1,49 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const concurrentEditExactSamePositionTest: TestDefinition = { + description: + "Both clients replace the same word in a file with different text " + + "while offline. After syncing, the merged result should contain " + + "both replacements.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "the quick brown fox" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { + type: "update", + client: 0, + path: "doc.md", + content: "the slow brown fox" + }, + { + type: "update", + client: 1, + path: "doc.md", + content: "the fast brown fox" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContains("doc.md", "slow", "fast", "brown fox"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts new file mode 100644 index 00000000..cd8046ce --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts @@ -0,0 +1,49 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition = { + description: + "One client renames X to Y while another creates a new file at Y, " + + "both offline. After syncing, Y should contain merged content from " + + "both the renamed file and the newly created file.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "X.md", + content: "original file X" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, + + { + type: "create", + client: 1, + path: "Y.md", + content: "brand new Y content" + }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertContains("Y (1).md", "original file X") + .assertContains("Y.md", "brand new Y content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts new file mode 100644 index 00000000..0ac0b721 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts @@ -0,0 +1,52 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition = { + description: + "One client renames X to Y while another creates a new file at Y, " + + "both offline. We can't merge the create because it would result in a cycle", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "X.md", + content: "original file X" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, + + { + type: "create", + client: 1, + path: "Y.md", + content: "brand new Y content" + }, + + { type: "enable-sync", client: 0 }, + { type: "sync", client: 0 }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileNotExists("X.md") + .assertFileExists("Y.md") + .assertFileExists("Y (1).md") + .assertAnyFileContains( + "original file X", + "brand new Y content" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts new file mode 100644 index 00000000..5337649d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts @@ -0,0 +1,61 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const concurrentRenameFirstWinsTest: TestDefinition = { + description: + "Both clients start online with the same file. Both go offline, " + + "rename the file to different paths, and edit it. When they reconnect, " + + "the first rename to reach the server wins the path and both content " + + "edits are merged.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "line 1\nline 2\nline 3" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "line 1\nline 2\nline 3"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { + type: "update", + client: 0, + path: "B.md", + content: "edit from 0\nline 2\nline 3" + }, + + { type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" }, + { + type: "update", + client: 1, + path: "C.md", + content: "line 1\nline 2\nedit from 1" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") + .assertFileCount(2) + .assertContent("B.md", "edit from 0\nline 2\nline 3") + .assertContent("C.md", "line 1\nline 2\nedit from 1"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts new file mode 100644 index 00000000..0b72c0f3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts @@ -0,0 +1,39 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const concurrentRenameSameTargetTest: TestDefinition = { + description: + "One client renames A to C while the other renames B to C, both offline. " + + "After syncing, both file contents should be preserved via path deconfliction.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content-a" }, + { type: "create", client: 0, path: "B.md", content: "content-b" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, + { type: "sync", client: 0 }, + + { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertFileExists("C.md") + .assertFileExists("C (1).md") + .assertAnyFileContains("content-a", "content-b"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts b/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts new file mode 100644 index 00000000..d21ce16b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts @@ -0,0 +1,51 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const concurrentUpdateDiffConsistencyTest: TestDefinition = { + description: + "Both clients edit different sections of the same file while offline. " + + "After syncing, the merged file should contain both edits.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "header\nmiddle\nfooter" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { + type: "update", + client: 0, + path: "doc.md", + content: "header by 0\nmiddle\nfooter" + }, + { + type: "update", + client: 1, + path: "doc.md", + content: "header\nmiddle\nfooter by 1" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContent( + "doc.md", + "header by 0\nmiddle\nfooter by 1" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts b/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts new file mode 100644 index 00000000..6c766001 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts @@ -0,0 +1,27 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const createDeleteNoopTest: TestDefinition = { + description: + "A client creates a file, updates it multiple times, then deletes it, all while " + + "offline. After syncing, neither client should have the file.", + clients: 2, + steps: [ + { type: "enable-sync", client: 1 }, + + { type: "create", client: 0, path: "temp.md", content: "version 1" }, + { type: "update", client: 0, path: "temp.md", content: "version 2" }, + { type: "update", client: 0, path: "temp.md", content: "version 3" }, + { type: "delete", client: 0, path: "temp.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("temp.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts b/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts new file mode 100644 index 00000000..0fe51106 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts @@ -0,0 +1,50 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const createDuringReconciliationTest: TestDefinition = { + description: + "Client creates two files while offline, reconnects, then immediately " + + "creates a third file. All three files should sync to the other client.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { + type: "create", + client: 0, + path: "A.md", + content: "offline A" + }, + { + type: "create", + client: 0, + path: "B.md", + content: "offline B" + }, + + { type: "enable-sync", client: 0 }, + + { + type: "create", + client: 0, + path: "C.md", + content: "post-reconnect C" + }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(3) + .assertContent("A.md", "offline A") + .assertContent("B.md", "offline B") + .assertContent("C.md", "post-reconnect C"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts b/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts new file mode 100644 index 00000000..ef7ea5c3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts @@ -0,0 +1,37 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const createMergeDeleteTest: TestDefinition = { + description: + "Two clients create A.md offline with different content. Both come online and " + + "the content is merged. Then one client deletes A.md. Both clients should " + + "converge on an empty state.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "from-zero" }, + { type: "create", client: 1, path: "A.md", content: "from-one" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContains("A.md", "from-zero", "from-one"); + } + }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0).assertFileNotExists("A.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts b/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts new file mode 100644 index 00000000..a9bc37d4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts @@ -0,0 +1,59 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const createMergePreservesRenamedUpdateTest: TestDefinition = { + description: + "Both clients create the same file, which gets merged. One client goes " + + "offline, renames the file, updates it, and creates a new file at the " + + "original path. After reconnecting, the updated content must be preserved.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "alpha" }, + { type: "create", client: 1, path: "doc.md", content: "beta" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertContains("doc.md", "alpha", "beta"); + } + }, + + { type: "disable-sync", client: 1 }, + + { + type: "rename", + client: 1, + oldPath: "doc.md", + newPath: "moved.md" + }, + { + type: "update", + client: 1, + path: "moved.md", + content: "alpha beta extra-update" + }, + + { + type: "create", + client: 1, + path: "doc.md", + content: "new-content" + }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertContent("moved.md", "alpha beta extra-update") + .assertContent("doc.md", "new-content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts b/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts new file mode 100644 index 00000000..b9e16c90 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts @@ -0,0 +1,34 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const createRenameCreateSamePathTest: TestDefinition = { + description: + "Client creates A.md, renames to B.md, creates new A.md, renames " + + "to C.md, creates yet another A.md. All three files should exist " + + "as separate documents on both clients.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "first file" }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + + { type: "create", client: 0, path: "A.md", content: "second file" }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, + + { type: "create", client: 0, path: "A.md", content: "third file" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(3) + .assertContent("B.md", "first file") + .assertContent("C.md", "second file") + .assertContent("A.md", "third file"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts new file mode 100644 index 00000000..aa24b110 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const createRenameResponseSkipsFileTest: TestDefinition = { + description: + "Client 0 creates a file online then immediately renames it. " + + "Client 1 must receive the file content at the renamed path.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { + type: "create", + client: 0, + path: "doc.md", + content: "the-content" + }, + + { + type: "rename", + client: 0, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertAnyFileContains("the-content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts b/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts new file mode 100644 index 00000000..9b752d05 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts @@ -0,0 +1,32 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const createUpdateCoalesceServerPauseTest: TestDefinition = { + description: + "Client creates a file and immediately updates it while the server is " + + "paused. When the server resumes, both clients should have the final " + + "updated content.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "pause-server" }, + + { type: "create", client: 0, path: "doc.md", content: "initial" }, + { type: "update", client: 0, path: "doc.md", content: "final version" }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContent("doc.md", "final version"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts new file mode 100644 index 00000000..dfef9961 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts @@ -0,0 +1,40 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const deleteByOtherClientThenRecreateTest: TestDefinition = { + description: + "Client 1 deletes a file and the delete propagates. Then client 0 " + + "creates a new file at the same path. Both clients must have the file.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md"); + } + }, + + { + type: "create", + client: 0, + path: "A.md", + content: "recreated by client 0" + }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "recreated by client 0"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts new file mode 100644 index 00000000..3ba393b8 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts @@ -0,0 +1,35 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const deleteDuringPendingCreateTest: TestDefinition = { + description: + "Client 0 creates a file while the server is paused, then deletes it before the server resumes. " + + "After resume, the file should end up deleted on both clients.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "ephemeral.md", + content: "this will be deleted" + }, + + { type: "delete", client: 0, path: "ephemeral.md" }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0).assertFileNotExists("ephemeral.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts new file mode 100644 index 00000000..6cb4cb98 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const deleteRecreateConcurrentUpdateTest: TestDefinition = { + description: + "Client 0 deletes and recreates A.md with new content while offline. Client 1 updates A.md concurrently. " + + "After client 0 reconnects, both clients must converge with client 0's recreated content preserved.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "delete", client: 0, path: "A.md" }, + { + type: "create", + client: 0, + path: "A.md", + content: "recreated by client 0" + }, + + { + type: "update", + client: 1, + path: "A.md", + content: "updated by client 1" + }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileExists("A.md").assertContains("A.md", "recreated"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts new file mode 100644 index 00000000..782c3cd5 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts @@ -0,0 +1,54 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const deleteRecreateDifferentContentTest: TestDefinition = { + description: + "Client 0 deletes and recreates A.md with new content offline while client 1 edits A.md offline. " + + "Both clients should converge with content from both sides merged.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "original content here" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "delete", client: 0, path: "A.md" }, + { + type: "create", + client: 0, + path: "A.md", + content: "brand new content" + }, + + { + type: "update", + client: 1, + path: "A.md", + content: "edit from client 1" + }, + + { type: "enable-sync", client: 0 }, + { type: "sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "A.md", + "brand new", + "client 1" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts new file mode 100644 index 00000000..dde8d341 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts @@ -0,0 +1,34 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const deleteRecreateSamePathTest: TestDefinition = { + description: + "Client 0 creates A.md, syncs. Then deletes A.md and creates a new A.md " + + "with different content. Both clients should converge on the new content.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "version 1" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "version 1"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "delete", client: 0, path: "A.md" }, + { type: "create", client: 0, path: "A.md", content: "version 2" }, + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "version 2"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts b/frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts new file mode 100644 index 00000000..80e95f48 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts @@ -0,0 +1,52 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefinition = + { + description: + "A local delete for a recreated pending create must target the " + + "new pending create, not an older same-path record whose server " + + "delete has been acked but whose WebSocket delete receipt is " + + "still paused.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-websocket", client: 0 }, + { type: "pause-server" }, + { + type: "create", + client: 0, + path: "binary-14.bin", + content: "BINARY:first" + }, + { type: "sleep", ms: 100 }, + { type: "delete", client: 0, path: "binary-14.bin" }, + { type: "resume-server" }, + { type: "sync", client: 0 }, + + { type: "pause-server" }, + { + type: "create", + client: 0, + path: "binary-14.bin", + content: "BINARY:second" + }, + { type: "sleep", ms: 100 }, + { type: "delete", client: 0, path: "binary-14.bin" }, + { type: "resume-server" }, + { type: "sync", client: 0 }, + + { type: "resume-websocket", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(0); + } + } + ] + }; diff --git a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts new file mode 100644 index 00000000..91e6289b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts @@ -0,0 +1,43 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const deleteRenameConflictTest: TestDefinition = { + description: + "Client 0 deletes A.md while client 1 renames A.md to C.md offline. " + + "After client 1 reconnects, both clients should converge to the same state.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content-a" }, + { type: "create", client: 0, path: "B.md", content: "content-b" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileExists("A.md").assertFileExists("B.md"); + } + }, + + { type: "disable-sync", client: 1 }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "sync", client: 0 }, + + { type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("B.md", "content-b"); + s.assertFileNotExists("A.md"); + s.ifFileExists("C.md", (inner) => + inner.assertContent("C.md", "content-a") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts new file mode 100644 index 00000000..cb995243 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts @@ -0,0 +1,38 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const displacedFileNotMarkedDeletedTest: TestDefinition = { + description: + "Client 0 creates a new file at path B.md while client 1 renames " + + "A.md to B.md. The remote download of B.md displaces client 1's " + + "renamed file. The displaced document must not be permanently " + + "marked as recently deleted, so it can still be synced.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content of A" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { type: "create", client: 0, path: "B.md", content: "content of B" }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, + { type: "sync", client: 0 }, + + { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, + { type: "enable-sync", client: 1 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertContent("B.md", "content of B") + .assertContent("C.md", "content of A"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts new file mode 100644 index 00000000..744d862e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts @@ -0,0 +1,77 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const doubleOfflineCycleTest: TestDefinition = { + description: + "Client 0 goes through three offline-edit-reconnect cycles. " + + "Each offline edit must propagate to client 1 after reconnection.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "initial" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("doc.md", "initial"); + } + }, + + { type: "disable-sync", client: 0 }, + { + type: "update", + client: 0, + path: "doc.md", + content: "first edit" + }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("doc.md", "first edit"); + } + }, + + { type: "disable-sync", client: 0 }, + { + type: "update", + client: 0, + path: "doc.md", + content: "second edit" + }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("doc.md", "second edit"); + } + }, + + { type: "disable-sync", client: 0 }, + { + type: "update", + client: 0, + path: "doc.md", + content: "third edit" + }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "third edit"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts new file mode 100644 index 00000000..551c702d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts @@ -0,0 +1,33 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const idempotencyAfterServerPauseTest: TestDefinition = { + description: + "Client 0 creates a file, then the server is paused mid-response. " + + "After the server resumes, both clients must converge to a single copy of the file with no duplicates.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "create", + client: 0, + path: "doc.md", + content: "important data" + }, + { type: "pause-server" }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "important data"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts new file mode 100644 index 00000000..3ae7eda5 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts @@ -0,0 +1,29 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const interruptedDeleteRetryTest: TestDefinition = { + description: + "Client 0 deletes a file, then the server is paused. " + + "After the server resumes, both clients should have zero files.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "to be deleted" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 0, path: "doc.md" }, + + { type: "pause-server" }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts new file mode 100644 index 00000000..20925889 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts @@ -0,0 +1,41 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const localEditLostDuringCreateMergeTest: TestDefinition = { + description: + "Both clients create doc.md with different content while offline. " + + "Client 0 also edits the file before syncing. After both connect, " + + "the merged result should contain content from both clients.", + clients: 2, + steps: [ + { type: "create", client: 1, path: "doc.md", content: "from-client-1" }, + { + type: "create", + client: 0, + path: "doc.md", + content: "from-client-0" + }, + { + type: "update", + client: 0, + path: "doc.md", + content: "local-edit-during-create" + }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "doc.md", + "from-client-1", + "local-edit-during-create" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts new file mode 100644 index 00000000..c2b80af3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts @@ -0,0 +1,80 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const localRenameSurvivesRemoteRenameTest: TestDefinition = { + description: + "Drain processes a RemoteChange (remote rename for doc D) while a " + + "LocalUpdate (user rename of D) is also queued behind it. " + + "`processRemoteUpdate` moves the disk file and, because there is a " + + "pending LocalUpdate, takes the else branch — but its setDocument " + + "uses the stale `record.path` (= the user-rename target) instead of " + + "the actualPath the file just moved to. The queued LocalUpdate then " + + "reads from `record.path`, throws FileNotFoundError, and is " + + "silently dropped. Setup pins the queue order: a sentinel " + + "LocalUpdate keeps drain busy on a SIGSTOPped HTTP roundtrip while " + + "we resume client 0's WebSocket (enqueues RemoteChange) and then " + + "user-rename D (enqueues LocalUpdate after the RemoteChange). On " + + "server resume the drain pops the sentinel, then RemoteChange, then " + + "LocalUpdate — exactly the order that triggers the bug.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "create", client: 0, path: "doc.md", content: "v1\n" }, + { type: "create", client: 0, path: "sentinel.md", content: "s\n" }, + { type: "barrier" }, + + // Pause client 0's WebSocket so the upcoming remote rename buffers. + { type: "pause-websocket", client: 0 }, + + // Server applies remote rename of doc.md -> remote.md. Broadcast + // is buffered on client 0's WebSocket. + { type: "rename", client: 1, oldPath: "doc.md", newPath: "remote.md" }, + { type: "sync", client: 1 }, + + // Pause the server BEFORE arming the sentinel, so the sentinel's + // HTTP request will buffer at the kernel and keep drain occupied. + { type: "pause-server" }, + + // Sentinel: a LocalUpdate on a *different* doc that drain pops + // first. Its HTTP roundtrip stalls on SIGSTOP, freezing drain + // until we resume the server. While drain is frozen we can grow + // the queue with additional events whose order we control. + { + type: "update", + client: 0, + path: "sentinel.md", + content: "s\nedit\n" + }, + + // Resume the WebSocket — buffered remote rename enqueues as a + // RemoteChange. Drain is still stuck on the sentinel HTTP. + { type: "resume-websocket", client: 0 }, + + // User renames doc.md -> local.md on client 0. queue.enqueue + // mutates the doc's record.path to "local.md" and pushes a + // LocalUpdate(rename) onto the tail of the queue. Queue is now + // [sentinel-update (in-flight), RemoteChange, LocalUpdate-rename]. + { type: "rename", client: 0, oldPath: "doc.md", newPath: "local.md" }, + + // Resume the server. Drain pops sentinel-update (succeeds), then + // RemoteChange. Pre-fix: processRemoteUpdate moves disk + // local.md -> remote.md, takes the else branch, and + // setDocument(record.path = "local.md", …) leaves record.path + // stale. Drain pops the LocalUpdate-rename and reads from the + // stale record.path, hits FileNotFoundError, silent skip. + // Post-fix: when a local event is pending, we re-queue the + // remote update without touching disk or record, so the local + // rename drains first and both ends converge. + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(2); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts new file mode 100644 index 00000000..0d8348c0 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts @@ -0,0 +1,69 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const localUpdateSurvivesRemoteRenameTest: TestDefinition = { + description: + "Client 0 has a local content edit pending while a remote rename for " + + "the same doc arrives over the WebSocket. The remote rename's internal " + + "move relocates the disk file from the old path (where the user wrote) " + + "to the new server path. Previously, the queued LocalUpdate's " + + "`event.path` was left pointing at the now-vacated old path, so " + + "`skipIfOversized`'s `getFileSize(event.path)` threw " + + "`FileNotFoundError`, which `processEvent`'s catch silently swallowed " + + "as 'Skipping sync event 'local-update' because the file no longer " + + "exists' — and the user's edit was lost. The fix routes the size " + + "check through `tracked.path` (the doc's current disk path), " + + "matching the path `processLocalUpdate` itself reads from.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "v1\n" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Pause client 0's WebSocket so the upcoming remote rename buffers + // there until we've already enqueued client 0's local content + // edit. This guarantees the LocalUpdate sits in client 0's queue + // when the rename's RemoteChange drains. + { type: "pause-websocket", client: 0 }, + + { + type: "rename", + client: 1, + oldPath: "doc.md", + newPath: "renamed.md" + }, + { type: "sync", client: 1 }, + + // Client 0 still believes the file is at `doc.md` (its WebSocket is + // paused, so the rename hasn't reached it). The user edits content + // at `doc.md`. This pushes a LocalUpdate(D, path=doc.md, + // originalPath=doc.md, isUserRename=false) into client 0's queue. + { + type: "update", + client: 0, + path: "doc.md", + content: "v1\nclient 0 edit\n" + }, + + // Resume the WebSocket. The buffered remote rename (server-broadcast) + // drains. `processRemoteUpdate` does an internal `move(doc.md, + // renamed.md)` and, because there's a pending LocalUpdate for D, + // takes the else branch (re-enqueue v_K, setDocument(renamed.md, …)). + // Then drain reaches the LocalUpdate. Pre-fix: skipped silently. + // Post-fix: PUTs the user's content to the doc (at its new path, + // since this is a content-only edit, not a user rename). + { type: "resume-websocket", client: 0 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(1); + state.assertFileExists("renamed.md"); + state.assertContent("renamed.md", "v1\nclient 0 edit\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts new file mode 100644 index 00000000..d986a733 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts @@ -0,0 +1,46 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const mcCrossCreateRenameSameTargetTest: TestDefinition = { + description: + "Client 0 creates X.md, Client 1 creates Y.md. Both sync. Client 0 renames " + + "X.md -> Z.md. Client 1 (offline) renames Y.md -> Z.md. Both must converge " + + "with both contents preserved via path deconfliction.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "X.md", content: "content-x" }, + { type: "create", client: 1, path: "Y.md", content: "content-y" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileExists("X.md").assertFileExists("Y.md"); + } + }, + + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "X.md", newPath: "Z.md" }, + { type: "sync", client: 0 }, + + { type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(2) + .assertFileNotExists("X.md") + .assertFileNotExists("Y.md") + .assertFileExists("Z.md") + .assertAnyFileContains("content-x", "content-y"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts new file mode 100644 index 00000000..6727e99d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts @@ -0,0 +1,39 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const mcDeleteThenOfflineRenameTest: TestDefinition = { + description: + "Client 0 creates A.md, both sync. Client 1 goes offline. Client 0 deletes " + + "A.md and syncs. Client 1 (offline) renames A.md to B.md. Client 1 reconnects. " + + "Both must converge. C.md (unrelated) must be unaffected.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "create", client: 0, path: "C.md", content: "unrelated" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "sync", client: 0 }, + + { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("C.md", "unrelated").assertFileNotExists( + "A.md" + ); + s.ifFileExists("B.md", (inner) => + inner.assertContent("B.md", "original") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts new file mode 100644 index 00000000..8db90aab --- /dev/null +++ b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts @@ -0,0 +1,49 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const mcMultiDeleteOfflineRenameTest: TestDefinition = { + description: + "Client 0 creates 5 files. Client 1 deletes 2 while Client 0 (offline) " + + "renames one of the deleted files. Both must converge.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "file-1.md", content: "content-1" }, + { type: "create", client: 0, path: "file-2.md", content: "content-2" }, + { type: "create", client: 0, path: "file-3.md", content: "content-3" }, + { type: "create", client: 0, path: "file-4.md", content: "content-4" }, + { type: "create", client: 0, path: "file-5.md", content: "content-5" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { type: "delete", client: 1, path: "file-2.md" }, + { type: "delete", client: 1, path: "file-4.md" }, + { type: "sync", client: 1 }, + + { + type: "rename", + client: 0, + oldPath: "file-2.md", + newPath: "renamed.md" + }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileExists("file-1.md") + .assertFileExists("file-3.md") + .assertFileExists("file-5.md") + .assertFileNotExists("file-2.md") + .assertFileNotExists("file-4.md"); + s.ifFileExists("renamed.md", (inner) => + inner.assertContent("renamed.md", "content-2") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts new file mode 100644 index 00000000..4167b925 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts @@ -0,0 +1,41 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = { + description: + "Client 0 creates A.md. Client 1 renames to B.md. Client 2 (offline) " + + "updates A.md. All three converge with updated content at B.md.", + clients: 3, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "enable-sync", client: 2 }, + { type: "barrier" }, + + { type: "disable-sync", client: 2 }, + + { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, + { type: "sync", client: 1 }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 2, + path: "A.md", + content: "updated-by-client-2" + }, + + { type: "enable-sync", client: 2 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1) + .assertFileNotExists("A.md") + .assertContains("B.md", "updated-by-client-2"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts b/frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts new file mode 100644 index 00000000..e93240f9 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts @@ -0,0 +1,77 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const mergingUpdateResponseSurvivesUserRenameTest: TestDefinition = { + description: + "Client 1 sends a content update with a stale `parent_version_id` " + + "(its WebSocket is paused, so it hasn't seen Client 0's intervening " + + "edit). The server merges and replies with `MergingUpdate` carrying " + + "the merged text. Before the response lands, the user renames the " + + "doc on Client 1, vacating the disk path the in-flight " + + "`processLocalUpdate` captured. Pre-fix: " + + "`handleMaybeMergingResponse`'s `operations.write(diskPath, …)` " + + "hits the `we wont recreate it` early-return inside `write`, " + + "silently dropping the server-merged content — Client 0's edit is " + + "lost on Client 1's disk, and Client 1's next local-update PUT " + + "(rebased on the now-untracked merged version) deletes Client 0's " + + "edit on the server too. Post-fix: the response is written to the " + + "doc's current tracked disk path, preserving both edits.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "create", client: 0, path: "doc.md", content: "0\n" }, + { type: "barrier" }, + + // Stop Client 1 from seeing Client 0's next edit, so its next + // outbound PUT carries a stale `parent_version_id` and the server + // is forced to merge. + { type: "pause-websocket", client: 1 }, + + // Server now holds v_b = "0\nA\n". Client 1's tracked parent + // version stays at v_a = "0\n". + { type: "update", client: 0, path: "doc.md", content: "0\nA\n" }, + { type: "sync", client: 0 }, + + // Pause the server. Subsequent HTTP PUTs from Client 1 buffer at + // the OS layer until resume. This guarantees the merge response + // for Client 1's update is still in flight when the rename below + // mutates `queue.documents`. + { type: "pause-server" }, + + // Client 1 edits doc.md with "B". The drain pops the LocalUpdate, + // captures `diskPath = "doc.md"`, reads the file, and sends the + // HTTP PUT — which buffers because the server is SIGSTOPped. + { type: "update", client: 1, path: "doc.md", content: "0\nB\n" }, + + // User renames the file while the previous PUT is still in flight. + // `queue.enqueue`'s rename branch updates `documents` to point at + // `renamed.md` synchronously, but `processLocalUpdate`'s captured + // `diskPath` ("doc.md") is a local — it can't be retargeted. + { type: "rename", client: 1, oldPath: "doc.md", newPath: "renamed.md" }, + + // Resume the server. It reconciles parent=v_a, latest=v_b, + // new="0\nB\n" → v_c with both edits, replies `MergingUpdate`. + // Pre-fix: write("doc.md", …) sees no file at that path + // (renamed.md now holds the data) and bails out without ever + // writing the merged bytes. Post-fix: the merged bytes land at + // the tracked path (renamed.md). + { type: "resume-server" }, + { type: "resume-websocket", client: 1 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(1); + state.assertFileExists("renamed.md"); + state.assertFileNotExists("doc.md"); + // Both edits survive: Client 0's "A" and Client 1's "B". + // The reconcile may interleave them either way; assert + // both tokens are present in the converged content. + state.assertContains("renamed.md", "A", "B"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts new file mode 100644 index 00000000..86657f0f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts @@ -0,0 +1,43 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { + description: + "Client 0 renames A.md to B.md offline while client 1 updates A.md. " + + "After client 0 reconnects, both should have B.md with client 1's updated content.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "original content" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + + { + type: "update", + client: 1, + path: "A.md", + content: "updated by client 1" + }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1) + .assertFileNotExists("A.md") + .assertContains("B.md", "updated by client 1"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts b/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts new file mode 100644 index 00000000..fe9267d4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const moveChainThreeFilesTest: TestDefinition = { + description: + "Three files have their contents rotated (A gets C's content, B gets A's, C gets B's) " + + "while offline. After reconnecting, both clients should converge with the rotated contents.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "create", client: 0, path: "A.md", content: "was A" }, + { type: "create", client: 0, path: "B.md", content: "was B" }, + { type: "create", client: 0, path: "C.md", content: "was C" }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "delete", client: 0, path: "B.md" }, + { type: "delete", client: 0, path: "C.md" }, + + { type: "create", client: 0, path: "A.md", content: "was C" }, + { type: "create", client: 0, path: "B.md", content: "was A" }, + { type: "create", client: 0, path: "C.md", content: "was B" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(3) + .assertContent("A.md", "was C") + .assertContent("B.md", "was A") + .assertContent("C.md", "was B"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts b/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts new file mode 100644 index 00000000..2a9ce0b4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts @@ -0,0 +1,44 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const moveIdenticalContentAmbiguityTest: TestDefinition = { + description: + "Two files with identical content exist. One is deleted and the other renamed " + + "while offline. The system should still converge correctly despite the ambiguity.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "identical content" + }, + { + type: "create", + client: 0, + path: "B.md", + content: "identical content" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + { type: "delete", client: 1, path: "A.md" }, + { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertContent("C.md", "identical content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts new file mode 100644 index 00000000..13e27349 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts @@ -0,0 +1,48 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const movePreservesRemoteUpdateTest: TestDefinition = { + description: + "Client 0 renames a file offline while client 1 edits it offline. " + + "After both reconnect, the renamed file should contain client 1's edit.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "line 1\nline 2" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" }, + { + type: "update", + client: 1, + path: "doc.md", + content: "line 1\nclient 1 edit\nline 2" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1); + const [content] = Array.from(s.files.values()); + if (!content.includes("client 1 edit")) { + throw new Error( + `Expected merged content to include "client 1 edit", got: "${content}"` + ); + } + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts new file mode 100644 index 00000000..433bf01b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts @@ -0,0 +1,38 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const moveRemoteUpdateRevertsRenameTest: TestDefinition = { + description: + "Client 1 updates a file while client 0 is offline. Client 0 reconnects and renames the file. " + + "Both clients should converge with client 1's updated content.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { + type: "update", + client: 1, + path: "doc.md", + content: "updated by client 1" + }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "renamed.md", + "updated by client 1" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts new file mode 100644 index 00000000..4f5feab5 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts @@ -0,0 +1,34 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const moveThenDeleteStalePathTest: TestDefinition = { + description: + "Client 0 renames A.md to B.md and immediately deletes B.md. " + + "Both clients should end up with zero files.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "content to delete" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { type: "delete", client: 0, path: "B.md" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0) + .assertFileNotExists("A.md") + .assertFileNotExists("B.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts new file mode 100644 index 00000000..a47f5a2a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts @@ -0,0 +1,45 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const multiFileOperationsTest: TestDefinition = { + description: + "Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " + + "After client 1 reconnects, both clients must converge with B.md updated and C.md intact.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content-a" }, + { type: "create", client: 0, path: "B.md", content: "content-b" }, + { type: "create", client: 0, path: "C.md", content: "content-c" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 1, + path: "B.md", + content: "updated by client 1" + }, + { type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContains("B.md", "updated") + .assertFileExists("C.md") + .assertFileNotExists("A.md"); + s.ifFileExists("D.md", (inner) => + inner.assertContent("D.md", "content-a") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts new file mode 100644 index 00000000..6c946b9c --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts @@ -0,0 +1,59 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineConcurrentRenamesTest: TestDefinition = { + description: + "Client 0 creates A.md and syncs to both clients. Both clients go offline. " + + "Client 0 renames A.md to B.md. Client 1 renames A.md to C.md. " + + "Both reconnect. The system must converge -- both clients should " + + "agree on the final state and the content must not be lost.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "shared-content" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "shared-content"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { + type: "rename", + client: 0, + oldPath: "A.md", + newPath: "B.md" + }, + + { + type: "rename", + client: 1, + oldPath: "A.md", + newPath: "C.md" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") + .assertFileCount(1) + .assertAnyFileContains("shared-content"); + s.ifFileExists("B.md", (inner) => + inner.assertContent("B.md", "shared-content") + ); + s.ifFileExists("C.md", (inner) => + inner.assertContent("C.md", "shared-content") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-create-same-path-mergeable.test.ts b/frontend/deterministic-tests/src/tests/offline-create-same-path-mergeable.test.ts new file mode 100644 index 00000000..cbd59a4a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-create-same-path-mergeable.test.ts @@ -0,0 +1,41 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineCreateSamePathMergeableTest: TestDefinition = { + description: + "Both clients create a file at the same path while offline with different text content. " + + "After both sync, both clients must converge to a merged result containing both contributions.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "notes.md", + content: "alpha wrote this line" + }, + { + type: "create", + client: 1, + path: "notes.md", + content: "beta wrote this different line" + }, + + { type: "enable-sync", client: 0 }, + { type: "sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1) + .assertFileExists("notes.md") + .assertContains( + "notes.md", + "alpha wrote this line", + "beta wrote this different line" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts new file mode 100644 index 00000000..1e9ea8f7 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts @@ -0,0 +1,38 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineDeleteRemoteRenameTest: TestDefinition = { + description: + "Client 0 deletes A.md offline while client 1 renames it to A_renamed.md. " + + "After client 0 reconnects, both clients must converge.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content-a" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "delete", client: 0, path: "A.md" }, + + { + type: "rename", + client: 1, + oldPath: "A.md", + newPath: "A_renamed.md" + }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertFileNotExists( + "A_renamed.md" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts new file mode 100644 index 00000000..21e81aa6 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts @@ -0,0 +1,46 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { + description: + "Client 0 deletes A.md offline while client 1 updates it. Both clients must converge.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "original content" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original content"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "delete", client: 0, path: "A.md" }, + + { + type: "update", + client: 1, + path: "A.md", + content: "important update by client 1" + }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts new file mode 100644 index 00000000..ffc41b89 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts @@ -0,0 +1,49 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineEditRemoteRenameTest: TestDefinition = { + description: + "Client 0 edits A.md offline while client 1 renames A.md to B.md. " + + "After client 0 reconnects, the edit must appear in B.md and A.md must not exist.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original"); + } + }, + + { type: "disable-sync", client: 0 }, + { + type: "update", + client: 0, + path: "A.md", + content: "edited by client 0" + }, + + { + type: "rename", + client: 1, + oldPath: "A.md", + newPath: "B.md" + }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") + .assertFileCount(1) + .assertContains("B.md", "edited by client 0"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts new file mode 100644 index 00000000..970eabd3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts @@ -0,0 +1,51 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineEditThenMoveSameContentTest: TestDefinition = { + description: + "A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "content A" + }, + { + type: "create", + client: 0, + path: "B.md", + content: "content B" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { type: "delete", client: 0, path: "A.md" }, + + { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, + + { + type: "update", + client: 0, + path: "C.md", + content: "content A" + }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertContent("C.md", "content A") + .assertFileCount(1); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts new file mode 100644 index 00000000..da875b6e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts @@ -0,0 +1,57 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineMixedOperationsTest: TestDefinition = { + description: + "Client 0 creates 3 files, syncs to both clients. Client 0 goes offline, " + + "deletes file 1, renames file 2 to a new name, and edits file 3. " + + "When Client 0 reconnects, all three operations should propagate to Client 1.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "file1.md", content: "content-1" }, + { type: "create", client: 0, path: "file2.md", content: "content-2" }, + { type: "create", client: 0, path: "file3.md", content: "content-3" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("file1.md", "content-1") + .assertContent("file2.md", "content-2") + .assertContent("file3.md", "content-3"); + } + }, + + { type: "disable-sync", client: 0 }, + + { type: "delete", client: 0, path: "file1.md" }, + { + type: "rename", + client: 0, + oldPath: "file2.md", + newPath: "moved.md" + }, + { + type: "update", + client: 0, + path: "file3.md", + content: "updated-content-3" + }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("file1.md") + .assertFileNotExists("file2.md") + .assertContent("moved.md", "content-2") + .assertContent("file3.md", "updated-content-3") + .assertFileCount(2); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts new file mode 100644 index 00000000..f8e92bd9 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineMoveThenRemoteDeleteTest: TestDefinition = { + description: + "Client 0 renames A.md to B.md offline while client 1 deletes A.md. " + + "Both clients must converge to having no files.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "content to delete" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts new file mode 100644 index 00000000..6341fe8f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts @@ -0,0 +1,40 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineMultipleEditsTest: TestDefinition = { + description: + "Client 0 creates a file and syncs. Client 0 goes offline, edits the file " + + "5 times with different content. When Client 0 reconnects, both clients " + + "must converge to the final version.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("doc.md", "original"); + } + }, + + { type: "disable-sync", client: 0 }, + + { type: "update", client: 0, path: "doc.md", content: "edit-1" }, + { type: "update", client: 0, path: "doc.md", content: "edit-2" }, + { type: "update", client: 0, path: "doc.md", content: "edit-3" }, + { type: "update", client: 0, path: "doc.md", content: "edit-4" }, + { type: "update", client: 0, path: "doc.md", content: "edit-5-final" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "edit-5-final"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts new file mode 100644 index 00000000..836c7fb2 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts @@ -0,0 +1,43 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineRenameAndEditTest: TestDefinition = { + description: + "Client 0 creates A.md and syncs. Client 0 goes offline, renames A.md " + + "to B.md, then edits B.md. When Client 0 reconnects, the rename and edit " + + "should both propagate to Client 1.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { + type: "update", + client: 0, + path: "B.md", + content: "edited after rename" + }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") + .assertFileCount(1) + .assertContent("B.md", "edited after rename"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts new file mode 100644 index 00000000..c1b2913a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts @@ -0,0 +1,51 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { + description: + "Client 0 renames X.md to Y.md while offline. Client 1 updates X.md " + + "(same document). When Client 0 reconnects, the rename and update " + + "should merge. Y.md should exist with Client 1's content.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "X.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("X.md", "original"); + } + }, + + { type: "disable-sync", client: 0 }, + { + type: "rename", + client: 0, + oldPath: "X.md", + newPath: "Y.md" + }, + + { + type: "update", + client: 1, + path: "X.md", + content: "updated-by-client-1" + }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "Y.md", + "updated-by-client-1" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts new file mode 100644 index 00000000..3442cda7 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts @@ -0,0 +1,75 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { + description: + "Client 0 goes offline, updates A.md and B.md, then deletes B.md. " + + "Client 1 updates B.md while Client 0 is offline. When Client 0 " + + "reconnects, A.md should have the update and B.md should be " + + "consistently resolved (delete wins).", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "A original" + }, + { + type: "create", + client: 0, + path: "B.md", + content: "B original" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "A original").assertContent( + "B.md", + "B original" + ); + } + }, + + { type: "disable-sync", client: 0 }, + + { + type: "update", + client: 0, + path: "A.md", + content: "A updated by client 0" + }, + { + type: "update", + client: 0, + path: "B.md", + content: "B updated by client 0" + }, + + { type: "delete", client: 0, path: "B.md" }, + + { + type: "update", + client: 1, + path: "B.md", + content: "B updated by client 1" + }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent( + "A.md", + "A updated by client 0" + ).assertFileNotExists("B.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts b/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts new file mode 100644 index 00000000..b951b0be --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts @@ -0,0 +1,34 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const onlineBothCreateSamePathDeconflictTest: TestDefinition = { + description: + "Both clients create a file at the same path while online. " + + "One client's create gets deconflicted by the server. " + + "Both files must exist on both clients after convergence.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-websocket", client: 1 }, + { type: "create", client: 0, path: "A.md", content: " from-client-0 " }, + { type: "update", client: 0, path: "A.md", content: " updated-by-0 " }, + { type: "sync" }, + + { type: "create", client: 1, path: "A.md", content: " from-client-1 " }, + { type: "resume-websocket", client: 1 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContains("A.md", "updated-by-0", "from-client-1 "); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts new file mode 100644 index 00000000..f86b3347 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts @@ -0,0 +1,41 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = { + description: + "Client 0 creates a binary file and renames it while offline, then reconnects and immediately deletes it. " + + "Both clients must converge to zero files.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { + type: "create", + client: 0, + path: "data.bin", + content: "BINARY:offline-content" + }, + { + type: "rename", + client: 0, + oldPath: "data.bin", + newPath: "moved.bin" + }, + + { type: "enable-sync", client: 0 }, + { type: "delete", client: 0, path: "moved.bin" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts new file mode 100644 index 00000000..e0ddc21a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts @@ -0,0 +1,48 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = { + description: + "Client 0 creates a binary file and updates it while client 1 also " + + "creates a binary file at the same path. Both clients are online. " + + "Both clients must end up with the same file set.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "pause-websocket", client: 1 }, + { + type: "create", + client: 0, + path: "data.bin", + content: "BINARY:content-v1" + }, + { + type: "update", + client: 0, + path: "data.bin", + content: "BINARY:content-v2" + }, + { + type: "create", + client: 1, + path: "data.bin", + content: "BINARY:other-content" + }, + { type: "resume-websocket", client: 1 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertNoFileContains("content-v1") + .assertAnyFileContains("content-v2") + .assertAnyFileContains("other-content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts new file mode 100644 index 00000000..de5d6c89 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts @@ -0,0 +1,37 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const onlineDeleteRecreateRapidCycleTest: TestDefinition = { + description: + "A file is deleted and recreated multiple times by alternating clients while both are online. " + + "Both clients must converge after each cycle.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "round 0" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 0, path: "A.md", content: "round 1" }, + { type: "barrier" }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 1, path: "A.md", content: "round 2" }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 0, path: "A.md", content: "round 3" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "round 3"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts new file mode 100644 index 00000000..d3a9d84e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts @@ -0,0 +1,31 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const onlineEditVsDeleteConvergenceTest: TestDefinition = { + description: + "Both clients are online. Client 0 edits a file while client 1 " + + "deletes it. The clients must converge to the same state.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "update", + client: 0, + path: "A.md", + content: "edited by client 0" + }, + { type: "delete", client: 1, path: "A.md" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts new file mode 100644 index 00000000..a93a6f69 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts @@ -0,0 +1,54 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const overlappingEditsSameSectionTest: TestDefinition = { + description: + "Both clients go offline and edit different parts of the same document. " + + "After both reconnect, both edits must be preserved without data loss.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "# Title\n\nfooter" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { + type: "update", + client: 0, + path: "doc.md", + content: "# Title\nalpha addition\n\nfooter" + }, + + { + type: "update", + client: 1, + path: "doc.md", + content: "# Title\n\nbeta addition\nfooter" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "doc.md", + "# Title", + "alpha addition", + "beta addition", + "footer" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts new file mode 100644 index 00000000..6d89acf4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { + description: + "Client 0 goes offline, both clients edit doc.md concurrently, " + + "then client 0 reconnects. Both edits must be preserved.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { type: "update", client: 1, path: "doc.md", content: "alpha bravo" }, + { type: "sync", client: 1 }, + + { type: "update", client: 0, path: "doc.md", content: "charlie delta" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "doc.md", + "alpha", + "charlie" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts b/frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts new file mode 100644 index 00000000..a29f8314 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts @@ -0,0 +1,56 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const queuedCreateDeleteDoesNotHijackReusedPathTest: TestDefinition = { + description: + "A create/delete pair that is still queued behind another request " + + "must collapse locally. It must not later read a different file " + + "that reused the same path before the queued create drained.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + { + type: "create", + client: 1, + path: "blocker.bin", + content: "BINARY:blocker" + }, + { type: "sleep", ms: 100 }, + { + type: "create", + client: 1, + path: "target.bin", + content: "BINARY:old" + }, + { type: "delete", client: 1, path: "target.bin" }, + { + type: "create", + client: 1, + path: "source.bin", + content: "BINARY:new" + }, + { + type: "rename", + client: 1, + oldPath: "source.bin", + newPath: "target.bin" + }, + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertContent("blocker.bin", "BINARY:blocker") + .assertContent("target.bin", "BINARY:new") + .assertFileNotExists("source.bin"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts new file mode 100644 index 00000000..f9c58753 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts @@ -0,0 +1,52 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { + description: + "Client 0 rapidly creates, updates, deletes, then re-creates a file while the server is paused. " + + "After the server resumes, client 1 must see only the final file.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "cycle.md", + content: "version 1" + }, + { + type: "update", + client: 0, + path: "cycle.md", + content: "version 2" + }, + { type: "delete", client: 0, path: "cycle.md" }, + + { type: "resume-server" }, + { type: "sync" }, + + { + type: "create", + client: 0, + path: "cycle.md", + content: "final creation" + }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "cycle.md", + "final creation" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts new file mode 100644 index 00000000..48c062e0 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts @@ -0,0 +1,48 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = { + description: + "Client 0 rapidly edits multiple files while client 1 deletes some of them, all while both are online. " + + "Both clients must converge to a consistent state.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content A" }, + { type: "create", client: 0, path: "B.md", content: "content B" }, + { type: "create", client: 0, path: "C.md", content: "content C" }, + { type: "create", client: 0, path: "D.md", content: "content D" }, + { type: "create", client: 0, path: "E.md", content: "content E" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "update", client: 0, path: "A.md", content: "A edit 1" }, + { type: "update", client: 0, path: "B.md", content: "B edit 1" }, + { type: "update", client: 0, path: "C.md", content: "C edit 1" }, + { type: "delete", client: 1, path: "A.md" }, + { type: "delete", client: 1, path: "C.md" }, + { type: "delete", client: 1, path: "E.md" }, + { type: "update", client: 0, path: "A.md", content: "A edit 2" }, + { type: "update", client: 0, path: "B.md", content: "B edit 2" }, + { type: "update", client: 0, path: "C.md", content: "C edit 2" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + for (const [path, content] of s.files) { + for (const clientFiles of s.clientFiles) { + if ( + clientFiles.has(path) && + clientFiles.get(path) !== content + ) { + throw new Error( + `Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"` + ); + } + } + } + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts new file mode 100644 index 00000000..6f97ff05 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts @@ -0,0 +1,49 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const rapidUpdatesAfterMergeTest: TestDefinition = { + description: + "Both clients create the same file offline, triggering a merge on sync. " + + "Client 0 then rapidly sends three updates. Both clients must converge to the final update.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "from client 0" }, + { type: "create", client: 1, path: "doc.md", content: "from client 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "update", + client: 0, + path: "doc.md", + content: "update 1" + }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 0, + path: "doc.md", + content: "update 2" + }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 0, + path: "doc.md", + content: "update 3" + }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains("doc.md", "update 3"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts new file mode 100644 index 00000000..c8e70243 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts @@ -0,0 +1,45 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const recentlyDeletedClearedOnReconnectTest: TestDefinition = { + description: + "After a client deletes a document and reconnects, it should " + + "accept new documents from other clients even if they happen to " + + "arrive at the same path as the deleted document.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "sync" }, + + { type: "delete", client: 0, path: "doc.md" }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { + type: "create", + client: 1, + path: "doc.md", + content: "new content from client 1" + }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "doc.md", + "new content from client 1" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts b/frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts new file mode 100644 index 00000000..ca184b27 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const remoteQuickWriteRenameBeforeRecordTest: TestDefinition = { + description: + "Client 0 receives a remote create and the user renames the new " + + "file immediately after the syncer writes it. The watcher event " + + "must bind to the new document instead of being dropped before " + + "the remote-create handler persists the record.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { + type: "rename-next-write", + client: 0, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + { type: "create", client: 1, path: "doc.md", content: "v1\n" }, + { type: "sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1); + s.assertFileExists("renamed.md"); + s.assertFileNotExists("doc.md"); + s.assertContent("renamed.md", "v1\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts b/frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts new file mode 100644 index 00000000..d30fdc67 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts @@ -0,0 +1,76 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = { + // TODO(refactor): the failure mode described below is the + // pre-refactor "deflect-to-conflict-uuid" path that no longer + // exists. Under the new model the wire loop never moves files for + // path placement, so the remote rename can't deflect anywhere; the + // reconciler waits for the slot to free. Convergence assertion is + // still valid (no conflict-uuid stashes, both files present, the + // local create lands at a server-deconflicted sibling). + description: + "Client 0 has doc D tracked at `original.md`. Client 1 owns doc E " + + "and renames it to `target.md` server-side. Before client 0's " + + "drain processes the WS broadcast for E, the user creates a new " + + "local file `target.md` (a different doc, untracked). When the " + + "buffered RemoteChange for E drains, the engine has to reconcile " + + "doc E onto `target.md` even though the slot is held by client " + + "0's pending LocalCreate. Convergence requires both clients end " + + "up with [target.md = E] and the local create lands at a " + + "server-deconflicted sibling (e.g. `target (1).md`).", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "create", client: 1, path: "original.md", content: "v1\n" }, + { type: "barrier" }, + + // Pause client 0's WS so the upcoming remote rename buffers and + // we can stage a colliding local create before the rename + // drains on client 0. + { type: "pause-websocket", client: 0 }, + + // Client 1 renames the doc. Server commits, broadcasts to + // client 0 (buffered). + { + type: "rename", + client: 1, + oldPath: "original.md", + newPath: "target.md" + }, + { type: "sync", client: 1 }, + + // Client 0 still believes the doc is at `original.md`. The user + // creates a NEW file at `target.md` (an unrelated untracked + // doc). Disk on client 0 now has both `original.md` (the + // tracked doc) and `target.md` (the new untracked file). + { type: "create", client: 0, path: "target.md", content: "extra\n" }, + + // Resume client 0's WS. The buffered RemoteChange drains. + // The reconciler must converge without ever leaving a + // conflict-uuid stash on disk. + { type: "resume-websocket", client: 0 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(2); + for (const path of state.files.keys()) { + if (path.startsWith("conflict-")) { + throw new Error( + `Unexpected conflict-uuid stash on a converged client: ${path}` + ); + } + } + state.assertFileExists("target.md"); + state.assertContent("target.md", "v1\n"); + // The local create gets server-deconflicted to a + // sibling path (e.g. `target (1).md`). + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts b/frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts new file mode 100644 index 00000000..eb2ed86d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts @@ -0,0 +1,59 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const remoteUpdateResurrectsDeletedDocTest: TestDefinition = { + description: + "Client 1 updates, deletes, and recreates P (with a new docId D2). " + + "While the buffered remote events are being processed by client 0, " + + "client 0 also makes a local edit to P. The local edit lands in the " + + "queue while v17 is mid-process, sending v17 down processRemoteUpdate's " + + "re-enqueue branch. The deferred v17 must NOT later resurrect D1 as a " + + "conflict-… file at P after the delete and the D2 create have drained.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "create", client: 1, path: "P.md", content: "v8 content\n" }, + { type: "barrier" }, + + { type: "pause-websocket", client: 0 }, + + { + type: "update", + client: 1, + path: "P.md", + content: "v17 content from client 1\n" + }, + { type: "sync", client: 1 }, + { type: "delete", client: 1, path: "P.md" }, + { type: "sync", client: 1 }, + { + type: "create", + client: 1, + path: "P.md", + content: "v21 content (D2)\n" + }, + { type: "sync", client: 1 }, + + { type: "resume-websocket", client: 0 }, + + { + type: "update", + client: 0, + path: "P.md", + content: "local edit by client 0\n" + }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContent("P.md", "v21 content (D2)\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts b/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts new file mode 100644 index 00000000..b78ad143 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts @@ -0,0 +1,84 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const remoteUpdateSurvivesUserRenameTest: TestDefinition = { + description: + "Client 0 updates a tracked doc; while Client 1 is processing the " + + "broadcast and parked on the GET for the new version's content, the " + + "user renames the doc on Client 1. Pre-fix: `processRemoteUpdate` " + + "captures `actualPath` before the await and, after the GET returns, " + + "calls `write(actualPath, …)` (no-op — file was renamed away), " + + "`updateCache(actualPath, …)`, and `setDocument(actualPath, …)`. " + + "`setDocument` mutates the same record in place so its `path` is " + + "yanked from the user's renamed slot back to the pre-rename path, " + + "wiping the rename out of the queue's documents map. The queued " + + "`LocalUpdate` then reads from the now-stale `record.path`, hits " + + "`FileNotFoundError`, and is silently dropped — the user's rename " + + "never reaches the server. Post-fix: the handler defers when a " + + "local event landed mid-await, so the rename drains first and " + + "the deferred remote update is folded into the broadcast that " + + "follows the rename round-trip.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "v1\n" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Buffer Client 1's incoming broadcasts so it doesn't see + // Client 0's update until we've paused the server. + { type: "pause-websocket", client: 1 }, + + // Server now holds v=2 of doc.md. + { type: "update", client: 0, path: "doc.md", content: "v2\n" }, + { type: "sync", client: 0 }, + + // Pause the server. Client 1's upcoming GET for the new version + // content blocks at the OS layer until resume. + { type: "pause-server" }, + + // Release the buffered broadcast. Client 1's drain enters + // `processRemoteUpdate`, captures `actualPath`, fires the GET, + // and parks awaiting the response. + { type: "resume-websocket", client: 1 }, + + // Yield long enough for the drain to traverse all microtask + // hops between the WS handler and the GET, so the HTTP request + // is queued at the (paused) server before the rename runs. + // Without this yield the rename would be enqueued before + // `processRemoteUpdate`'s entry-time `hasPendingLocalEvents` + // check and the early-defer branch would mask the bug. + { type: "sleep", ms: 50 }, + + // While the GET is in flight the user renames the doc. The queue + // mutates `record.path` to "renamed.md" in place and pushes a + // LocalUpdate carrying the rename target. + { + type: "rename", + client: 1, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + // Resume the server. The GET response unblocks + // `processRemoteUpdate`. With the fix in place it sees the + // queued LocalUpdate and defers; without the fix it walks past + // the rename and clobbers the documents map, dropping the + // pending LocalUpdate's read on the way back through. + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1); + s.assertFileExists("renamed.md"); + s.assertFileNotExists("doc.md"); + // Both edits survive: the user's rename and Client 0's + // content update at v=2. + s.assertContent("renamed.md", "v2\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts b/frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts new file mode 100644 index 00000000..822e83df --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts @@ -0,0 +1,64 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameChainDuringPendingCreateTest: TestDefinition = { + description: + "User creates a doc, then renames it twice while the LocalCreate's " + + "HTTP roundtrip is still in flight (server paused). Each rename " + + "pushes a LocalUpdate whose `documentId` is the create's Promise " + + "(see `pendingDocumentId` in `SyncEventQueue.enqueue`). After the " + + "create resolves, the first rename drains successfully and " + + "`setDocument` walks `events[]` to retarget queued LocalUpdates' " + + "`event.path` to the new disk location — but the comparison " + + "`e.documentId === record.documentId` mismatches the still-Promise " + + "references, so the second rename's `event.path` stays at the " + + "vacated previous slot. On the next drain step `skipIfOversized`'s " + + "`getFileSize(event.path)` throws FileNotFoundError, which " + + "`processEvent` swallows as 'Skipping sync event ... because the " + + "file no longer exists' — losing the user's final rename. " + + "Post-fix: `resolveCreate` (and the displacement-merge branch in " + + "`processCreate`) swap the Promise references for the resolved id " + + "before `setDocument` runs, so retarget works.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Pause the server so client 0's create stalls on the HTTP PUT + // while we queue rename events behind it. + { type: "pause-server" }, + + { type: "create", client: 0, path: "first.md", content: "v1\n" }, + { + type: "rename", + client: 0, + oldPath: "first.md", + newPath: "second.md" + }, + { + type: "rename", + client: 0, + oldPath: "second.md", + newPath: "third.md" + }, + + // Resume — drain pops LocalCreate (now resolves), then the two + // queued LocalUpdates. Pre-fix: only the first rename's + // file-system effect lands; the second is silently dropped. + // The server ends up with the doc at second.md, leaving + // client 0's local third.md untracked / out-of-sync. + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(1); + state.assertFileExists("third.md"); + state.assertContent("third.md", "v1\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts new file mode 100644 index 00000000..03196919 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts @@ -0,0 +1,50 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameChainThenDeleteTest: TestDefinition = { + description: + "Client 0 renames X.md to Y.md to Z.md, then deletes Z.md while client 1 is offline. " + + "After client 1 reconnects, both clients must have no files.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "X.md", content: "chain-content" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("X.md", "chain-content"); + } + }, + + { type: "disable-sync", client: 1 }, + + { + type: "rename", + client: 0, + oldPath: "X.md", + newPath: "Y.md" + }, + { type: "sync", client: 0 }, + { + type: "rename", + client: 0, + oldPath: "Y.md", + newPath: "Z.md" + }, + { type: "sync", client: 0 }, + { type: "delete", client: 0, path: "Z.md" }, + { type: "sync", client: 0 }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-chain.test.ts b/frontend/deterministic-tests/src/tests/rename-chain.test.ts new file mode 100644 index 00000000..8f9d7a7f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-chain.test.ts @@ -0,0 +1,34 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameChainTest: TestDefinition = { + description: + "Client 0 (offline) creates A.md, renames to B.md, then renames to C.md. " + + "When sync is enabled, only C.md should exist. Client 1 should receive C.md " + + "with the original content. Intermediate paths should never appear.", + clients: 2, + steps: [ + { type: "enable-sync", client: 1 }, + + { + type: "create", + client: 0, + path: "A.md", + content: "important content" + }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertContent("C.md", "important content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-circular.test.ts b/frontend/deterministic-tests/src/tests/rename-circular.test.ts new file mode 100644 index 00000000..44a65149 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-circular.test.ts @@ -0,0 +1,44 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameCircularTest: TestDefinition = { + description: + "Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, all three contents should exist across three files but paths may be deconflicted.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content-a" }, + { type: "create", client: 0, path: "B.md", content: "content-b" }, + { type: "create", client: 0, path: "C.md", content: "content-c" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "content-a") + .assertContent("B.md", "content-b") + .assertContent("C.md", "content-c"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" }, + { type: "rename", client: 0, oldPath: "C.md", newPath: "A.md" }, + { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, + { type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("temp-a.md") + .assertFileCount(3) + .assertAnyFileContains("content-c") + .assertAnyFileContains("content-a") + .assertAnyFileContains("content-b"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts new file mode 100644 index 00000000..fc6a00a7 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -0,0 +1,34 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameCreateConflictTest: TestDefinition = { + description: + "Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "create", client: 0, path: "A.md", content: "hi" }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "hi"); + } + }, + { type: "disable-sync", client: 0 }, + { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, + { type: "sync", client: 1 }, + { type: "create", client: 0, path: "B.md", content: "hi" }, + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(2) + .assertContent("B.md", "hi") + .assertContent("B (1).md", "hi"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts new file mode 100644 index 00000000..0b47c781 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts @@ -0,0 +1,51 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameOverwritesPendingCreateThenDeleteTest: TestDefinition = { + description: + "A pending local create at a path must not mask a synced document renamed onto that path; later rename/delete events still belong to the synced document.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { + type: "create", + client: 0, + path: "tracked.bin", + content: "BINARY:tracked" + }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "pending.bin", + content: "BINARY:pending" + }, + { + type: "rename", + client: 0, + oldPath: "tracked.bin", + newPath: "pending.bin" + }, + { + type: "rename", + client: 0, + oldPath: "pending.bin", + newPath: "final.bin" + }, + { type: "delete", client: 0, path: "final.bin" }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts new file mode 100644 index 00000000..26623c43 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renamePendingCreateBeforeResponseTest: TestDefinition = { + description: + "Client 0 creates a file while the server is paused, then renames it before the create completes. After the server resumes, both clients should converge with the file at the renamed path.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "doc.md", + content: "original-content" + }, + + { + type: "rename", + client: 0, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "renamed.md", + "original-content" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts b/frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts new file mode 100644 index 00000000..0906f209 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts @@ -0,0 +1,59 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renamePendingCreateOntoPendingDeletePathTest: TestDefinition = { + description: + "A pending create is renamed onto a path whose old server document " + + "has a queued delete. The delete must reach the server before the " + + "new create so the new generation is not merged into the soon-to-be " + + "deleted document.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "create", + client: 1, + path: "file-17.md", + content: "old\n" + }, + { type: "barrier" }, + + { type: "pause-server" }, + { + type: "create", + client: 1, + path: "blocker.md", + content: "blocker\n" + }, + { type: "sleep", ms: 100 }, + { + type: "create", + client: 1, + path: "file-23.md", + content: "new\n" + }, + { type: "delete", client: 1, path: "file-17.md" }, + { + type: "rename", + client: 1, + oldPath: "file-23.md", + newPath: "file-17.md" + }, + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertContent("blocker.md", "blocker\n") + .assertContent("file-17.md", "new\n") + .assertFileNotExists("file-23.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts new file mode 100644 index 00000000..0373debf --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts @@ -0,0 +1,40 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameRoundtripTest: TestDefinition = { + description: + "Client 0 creates A.md, renames it to B.md, then renames it back to A.md. After each step both clients sync. Both should end with only A.md at the original path.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original"); + } + }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertContent("B.md", "original"); + } + }, + + { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContent("A.md", "original"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-swap.test.ts b/frontend/deterministic-tests/src/tests/rename-swap.test.ts new file mode 100644 index 00000000..9910e8ef --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-swap.test.ts @@ -0,0 +1,44 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameSwapTest: TestDefinition = { + description: + "Client 0 has A.md and B.md synced. Goes offline and swaps them using " + + "a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " + + "When Client 0 reconnects, both contents should exist across two files.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content-a" }, + { type: "create", client: 0, path: "B.md", content: "content-b" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "content-a").assertContent( + "B.md", + "content-b" + ); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "temp.md" }, + { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, + { type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("temp.md") + .assertFileCount(2) + .assertAnyFileContains("content-b") + .assertAnyFileContains("content-a"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts new file mode 100644 index 00000000..34a3867c --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts @@ -0,0 +1,44 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { + description: + "Client 0 deletes A.md then renames B.md to A.md. After syncing, " + + "B's content should exist and the old A.md content should be gone. " + + "The server may deconflict the path if the delete and move arrive " + + "in the same transaction.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "content A" + }, + { + type: "create", + client: 0, + path: "B.md", + content: "content B" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "sync" }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "barrier" }, + + { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContains( + "A.md", + "content B" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts new file mode 100644 index 00000000..8747218a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts @@ -0,0 +1,43 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameToPendingPathFallbackTest: TestDefinition = { + description: + "Client 0 creates B.md and syncs. Goes offline, creates A.md, then renames B.md to A.md (overwriting the unsynced A). After reconnecting, B.md should be gone and A.md should have B's content.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "B.md", + content: "tracked B content" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { + type: "create", + client: 0, + path: "A.md", + content: "pending A content" + }, + + { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContains( + "A.md", + "tracked B content" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts new file mode 100644 index 00000000..18d4c101 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameUpdateConflictTest: TestDefinition = { + description: + "Client 0 renames A.md to B.md while client 1 updates A.md offline. After client 1 reconnects, both should converge with the update at B.md.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original"); + } + }, + + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 1, + path: "A.md", + content: "updated by client 1" + }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertContains("B.md", "updated"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts b/frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts new file mode 100644 index 00000000..3ffb376e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts @@ -0,0 +1,65 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renamedPendingCreateReusedPathThenDeleteTest: TestDefinition = { + description: + "A queued create is renamed away from file-59.md, a newer local " + + "file reuses file-59.md before the queued create drains, and the " + + "renamed-away generation is deleted. The delete must not erase or " + + "orphan the newer file-59.md generation.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + { + type: "create", + client: 1, + path: "blocker.md", + content: "blocker\n" + }, + { type: "sleep", ms: 100 }, + + { + type: "create", + client: 1, + path: "file-59.md", + content: "old\n" + }, + { + type: "rename", + client: 1, + oldPath: "file-59.md", + newPath: "file-33.md" + }, + { + type: "create", + client: 1, + path: "file-59.md", + content: "new\n" + }, + + { + type: "resume-server-until-history-then-pause", + client: 1, + syncType: "CREATE", + path: "file-33.md" + }, + { type: "delete", client: 1, path: "file-33.md" }, + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertContent("blocker.md", "blocker\n") + .assertContent("file-59.md", "new\n") + .assertFileNotExists("file-33.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts new file mode 100644 index 00000000..e0a1565c --- /dev/null +++ b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts @@ -0,0 +1,43 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { + description: + "Client 0 deletes a file. Client 1 toggles sync off and on " + + "(simulating reconnect). The deleted file should NOT reappear " + + "on Client 1 after the sync reset.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "ghost.md", + content: "should be deleted" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 0, path: "ghost.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("ghost.md"); + } + }, + + { type: "disable-sync", client: 1 }, + { type: "enable-sync", client: 1 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts new file mode 100644 index 00000000..2a3b5de4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts @@ -0,0 +1,82 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest: TestDefinition = + { + description: + "A remote create starts quick-writing at doc.md while a local " + + "create for the same path is queued and renamed to renamed.md. " + + "Because the local create was renamed before it reached the " + + "server, the two generations should remain separate tracked " + + "documents.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + + // Create a deleted latest version before client 1 joins. + // Catch-up will advance MinCovered with a non-contiguous id, + // keeping client 1's create lastSeen low enough to exercise + // the server's same-doc merge path from the e2e failure. + { + type: "create", + client: 0, + path: "history.md", + content: "history-v1" + }, + { type: "sync", client: 0 }, + { + type: "update", + client: 0, + path: "history.md", + content: "history-v2" + }, + { type: "sync", client: 0 }, + { type: "delete", client: 0, path: "history.md" }, + { type: "sync", client: 0 }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + + { type: "pause-websocket", client: 1 }, + + { + type: "create", + client: 0, + path: "doc.md", + content: "remote\n" + }, + { type: "sync", client: 0 }, + + // Let client 1's buffered RemoteCreate enter the quick-write + // path, but hold the content fetch until the local create has + // appeared and moved away from doc.md. + { type: "pause-server" }, + { type: "resume-websocket", client: 1 }, + { type: "sleep", ms: 100 }, + + { + type: "create", + client: 1, + path: "doc.md", + content: "local\n" + }, + { + type: "rename", + client: 1, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(2); + state.assertContent("doc.md", "remote\n"); + state.assertContent("renamed.md", "local\n"); + } + } + ] + }; diff --git a/frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts new file mode 100644 index 00000000..dee3a9ad --- /dev/null +++ b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts @@ -0,0 +1,121 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition = + { + description: + "Client B creates X with content C2; the server commits and " + + "broadcasts. Client A's WS is paused so the RemoteCreate buffers. " + + "Server is then paused so A's about-to-POST LocalCreate will " + + "hang. A creates X with content C1: file lands on disk, " + + "LocalCreate enqueues, drain starts the POST, the POST stalls " + + "at the paused server. A's WS is resumed: the buffered " + + "RemoteCreate for doc-X is delivered to A and enqueues behind " + + "the in-flight LocalCreate. Per the lazy-paths model, when " + + "the RemoteCreate is processed it observes that path X is " + + "occupied locally by A's pending-create bytes, so it tracks " + + "doc-X with `localPath = undefined` / `remoteRelativePath = " + + "X` and does NOT fetch content. The server is then resumed: " + + "A's LocalCreate POST returns. The server, finding X already " + + "taken by doc-X, replies with doc-X's existing documentId " + + "(typically a MergingUpdate carrying the merged bytes). A's " + + "processCreate handler detects that response.documentId " + + "matches the no-localPath record built from the RemoteCreate " + + "and collapses the two: it sets localPath = X on that " + + "record, writes the merged bytes, and resolves the pending " + + "create promise. Final state: exactly one file at X on both " + + "clients, both pointing at doc-X's documentId, content " + + "carrying both contributions, and no conflict-- " + + "stash anywhere.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Buffer broadcasts to client 0 (A) so client 1's create + // doesn't reach A's WS handler until we say so. + { type: "pause-websocket", client: 0 }, + + // Client 1 (B) commits doc-X at path X with content C2. + // The server commits, broadcasts (broadcast queued at A's + // paused WS). + { + type: "create", + client: 1, + path: "X.md", + content: "from-client-1 " + }, + { type: "sync", client: 1 }, + + // Pause the server so A's upcoming LocalCreate POST hangs. + // This holds A's drain on the in-flight POST while we + // release the WS so the RemoteCreate enqueues behind it. + { type: "pause-server" }, + + // Client 0 (A) creates X locally with content C1. The + // file lands on A's disk; LocalCreate enqueues; drain + // starts the POST; POST stalls at the paused server. + { + type: "create", + client: 0, + path: "X.md", + content: "from-client-0 " + }, + + // Release A's WS. The buffered RemoteCreate for doc-X is + // delivered to A and enqueues behind the in-flight + // LocalCreate. Whichever of (RemoteCreate processed first + // → no-localPath record, then LocalCreate POST returns + // with merging response that collapses) or (LocalCreate + // POST returns first with merging response that creates + // the canonical record, then RemoteCreate finds the doc + // already tracked by id and no-ops) actually plays out + // depends on the fine-grained interleaving the runtime + // produces, but both paths are required to converge to + // the same single-record same-docId state. + { type: "resume-websocket", client: 0 }, + + // Resume the server: A's LocalCreate POST completes. + // Server returns doc-X's existing documentId (MergingUpdate + // with merged content). processCreate runs the collapse + // path. + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(1); + state.assertFileExists("X.md"); + // Server-side merge of the two text creates must + // carry both contributions through to the + // converged file. + state.assertContains( + "X.md", + "from-client-0", + "from-client-1" + ); + // The lazy-paths collapse path must not leave a + // conflict-- stash on either client. + for (const path of state.files.keys()) { + if (path.startsWith("conflict-")) { + throw new Error( + `Unexpected conflict-uuid stash on a converged client: ${path}` + ); + } + } + for (const perClient of state.clientFiles) { + for (const path of perClient.keys()) { + if (path.startsWith("conflict-")) { + throw new Error( + `Unexpected conflict-uuid stash on a per-client view: ${path}` + ); + } + } + } + } + } + ] + }; diff --git a/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts b/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts new file mode 100644 index 00000000..ac8ed3ed --- /dev/null +++ b/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts @@ -0,0 +1,152 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const selfMergePendingRenameAliasesSecondCreateTest: TestDefinition = { + description: + "Single client makes two distinct creates that briefly share a path. " + + "Client 0 POSTs the first create at primary.md while the server is " + + "paused. While that POST is in flight: a second create is queued at " + + "staging.md, primary.md is renamed to moved.md (rewriting the in- " + + "flight create's event.path to moved.md and pushing a rename " + + "LocalUpdate at the queue tail), and staging.md is renamed onto the " + + "now-vacated primary.md slot (rewriting the second create's " + + "event.path to primary.md and pushing another rename LocalUpdate). " + + "Client 0's WS is paused throughout, so its watermark stays at 0. " + + "On resume the first POST commits Doc-X at primary.md (creation_vuid " + + "= N). The drain then processes the second LocalCreate (POST " + + "relativePath=primary.md, last_seen=0); the server's path-based " + + "dedup sees N > 0 and merges the second create into Doc-X " + + "(MergingUpdate). The buggy behaviour: processCreate's resolveCreate " + + "calls upsertRecord with localPath=primary.md, but the existing " + + "record (from the first create) already holds localPath=moved.md, " + + "and upsertRecord's `existing.localPath !== undefined` guard " + + "silently drops the new claim. The file at primary.md is left " + + "orphaned: tracked by no record, never broadcast, never deleted. " + + "After the user's renames the expected user-visible state is two " + + "distinct files at moved.md and primary.md — both clients must " + + "converge to that.", + clients: 2, + steps: [ + // Both clients online so the WS connection is established before + // the test starts pausing things. + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Pause client 0's WS so its MinCovered watermark stays at 0 + // through the whole bug sequence. The merge condition the + // server is going to fire is `creation_vuid > last_seen`; with + // a non-zero gap the same-device second create gets merged + // into the same-device first create. + { type: "pause-websocket", client: 0 }, + + // Client 1 commits a doc to push the server's vuid above 0. + // Without this filler, Doc-X's create vuid could be 1 and + // client 0's last_seen.add(1) would advance min to 1, killing + // the watermark gap that triggers the merge. + { + type: "create", + client: 1, + path: "filler.md", + content: "filler-content " + }, + { type: "sync", client: 1 }, + + // Pause the server so client 0's first create POST hangs in + // flight, giving us a deterministic window in which to enqueue + // the second create and the renames. + { type: "pause-server" }, + + // First create — Doc-X. The wire-loop drains it, captures + // requestPath = event.path = "primary.md", reads the bytes, + // sends the POST, and stalls on the response. + { + type: "create", + client: 0, + path: "primary.md", + content: "primary content " + }, + + // Make sure the POST is actually on the wire with + // relativePath="primary.md" before we rewrite event.path. + // Without this delay the rename can win the race, the POST + // goes out with relativePath="moved.md", and the server-side + // path-collision merge never fires. + { type: "sleep", ms: 100 }, + + // Second create at a staging path. The wire-loop is still + // blocked on Doc-X's POST, so this LocalCreate just queues at + // index 1. + { + type: "create", + client: 0, + path: "staging.md", + content: "secondary content " + }, + + // Rename Doc-X's path. enqueue's pending-create branch + // rewrites Doc-X's event.path in place (moved.md) and pushes + // a LocalUpdate(rename, originalPath=moved.md) at the END of + // the queue. Note the ordering: this LocalUpdate is enqueued + // AFTER the staging LocalCreate above. That ordering is + // load-bearing — it is what makes the second create's POST + // drain (and trigger the server-side merge) before Doc-X's + // rename PUT moves the doc away from primary.md on the + // server. + { + type: "rename", + client: 0, + oldPath: "primary.md", + newPath: "moved.md" + }, + + // Rename the staging file onto Doc-X's now-vacated primary.md + // slot. enqueue rewrites the staging LocalCreate's event.path + // to primary.md and pushes a LocalUpdate(rename, + // originalPath=primary.md) at the queue tail. After this the + // disk has: moved.md = Doc-X's bytes, primary.md = Doc-Y's + // bytes. + { + type: "rename", + client: 0, + oldPath: "staging.md", + newPath: "primary.md" + }, + + // Let everything fly: server processes the queued POSTs; + // client 0 catches up on broadcasts. + { type: "resume-server" }, + { type: "resume-websocket", client: 0 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + // The user did two distinct creates (Doc-X and Doc-Y); + // both contents must survive on both clients. + state.assertFileCount(3); + state.assertFileExists("filler.md"); + state.assertFileExists("moved.md"); + state.assertFileExists("primary.md"); + + // After the renames the user expects: + // - moved.md = the file that was originally created + // at primary.md (Doc-X's content). + // - primary.md = the file that was originally created + // at staging.md (Doc-Y's content). + state.assertContains("moved.md", "primary content"); + state.assertContains("primary.md", "secondary content"); + + // No content cross-contamination: each contribution + // should land in exactly one of the user-visible + // files. Under the bug, the orphan at primary.md + // carries Doc-X's content (because Doc-Y's PUT was + // aliased onto Doc-X's record and read Doc-X's bytes + // from moved.md), so this catches the leak too. + state.assertContentInAtMostOneFile("primary content"); + state.assertContentInAtMostOneFile("secondary content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts new file mode 100644 index 00000000..611e1ae3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts @@ -0,0 +1,43 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const sequentialCreateDuplicateContentTest: TestDefinition = { + description: + "Client 0 creates A.md, syncs, then creates B.md with identical content. Both files must remain as separate documents on both clients.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "identical content here" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "identical content here"); + } + }, + + { + type: "create", + client: 0, + path: "B.md", + content: "identical content here" + }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(2) + .assertContent("A.md", "identical content here") + .assertContent("B.md", "identical content here"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts new file mode 100644 index 00000000..f99cf92d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseBothClientsCreateTest: TestDefinition = { + description: + "Client 0 creates a file, then the server is paused. Client 1 creates a different file while the server is paused. After the server resumes, both files should exist on both clients.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "create", + client: 0, + path: "alpha.md", + content: "from client 0" + }, + { type: "pause-server" }, + + { + type: "create", + client: 1, + path: "beta.md", + content: "from client 1" + }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContains("alpha.md", "from client 0").assertContains( + "beta.md", + "from client 1" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts new file mode 100644 index 00000000..ff8cf194 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts @@ -0,0 +1,68 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseBothEditSameFileTest: TestDefinition = { + description: + "Both clients edit different sections of the same file while the server is paused. After resuming and converging, client 0 makes another edit to verify further updates still work correctly.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "shared.md", + content: "line 1: original\nline 2: original\nline 3: original" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "update", + client: 0, + path: "shared.md", + content: + "line 1: edited by client 0\nline 2: original\nline 3: original" + }, + { + type: "update", + client: 1, + path: "shared.md", + content: + "line 1: original\nline 2: original\nline 3: edited by client 1" + }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "shared.md", + "edited by client 0", + "edited by client 1" + ); + } + }, + + { + type: "update", + client: 0, + path: "shared.md", + content: "post-merge edit from client 0" + }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "shared.md", + "post-merge edit from client 0" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts new file mode 100644 index 00000000..5ac97f0d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts @@ -0,0 +1,38 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseDeleteRecreateTest: TestDefinition = { + description: + "Client 1 deletes a file and syncs. The server is paused, then client 0 creates at the same path. After the server resumes, both clients should have the recreated file.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "A.md", + content: "recreated during contention" + }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContent("A.md", "recreated during contention"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts new file mode 100644 index 00000000..b1739135 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts @@ -0,0 +1,50 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseRenameEditResumeTest: TestDefinition = { + description: + "Client 0 creates A.md and syncs. Server is paused. Client 0 " + + "renames A.md to B.md and edits B.md. Server resumes. Both the " + + "rename and edit should propagate to Client 1.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { + type: "create", + client: 0, + path: "A.md", + content: "original content" + }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original content"); + } + }, + + { type: "pause-server" }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { + type: "update", + client: 0, + path: "B.md", + content: "edited after rename during pause" + }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1) + .assertFileNotExists("A.md") + .assertContent("B.md", "edited after rename during pause"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts new file mode 100644 index 00000000..2389ccf5 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts @@ -0,0 +1,54 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseUpdateAndCreateTest: TestDefinition = { + description: + "Client 0 updates a shared file while client 1 creates a new file, both during a server pause. After the server resumes, both operations should complete and propagate to both clients.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { + type: "create", + client: 0, + path: "shared.md", + content: "initial content" + }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("shared.md", "initial content"); + } + }, + + { type: "pause-server" }, + + { + type: "update", + client: 0, + path: "shared.md", + content: "updated during pause" + }, + { + type: "create", + client: 1, + path: "new-file.md", + content: "created by client 1" + }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent( + "shared.md", + "updated during pause" + ).assertContent("new-file.md", "created by client 1"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts new file mode 100644 index 00000000..7ec116ac --- /dev/null +++ b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts @@ -0,0 +1,38 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const simultaneousCreateDeleteSamePathTest: TestDefinition = { + description: + "Client 0 creates A.md and syncs to both clients. Client 0 deletes A.md while " + + "Client 1 (offline) updates A.md with different content. When Client 1 reconnects, " + + "the update and delete must be reconciled. Both clients must converge.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original from 0" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 1, + path: "A.md", + content: "modified by 1 while offline" + }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/text-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/text-pending-create-not-displaced.test.ts new file mode 100644 index 00000000..28243525 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/text-pending-create-not-displaced.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const textPendingCreateNotDisplacedTest: TestDefinition = { + description: + "Two clients each create a text file at the same path while offline. " + + "After syncing, the file should contain merged content from both clients.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "data.txt", + content: "text data from client-0" + }, + { + type: "create", + client: 1, + path: "data.txt", + content: "text data from client-1" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1) + .assertFileExists("data.txt") + .assertAnyFileContains("client-0", "client-1"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts new file mode 100644 index 00000000..80478adc --- /dev/null +++ b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts @@ -0,0 +1,55 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const threeClientRenameCreateDeleteTest: TestDefinition = { + description: + "Client 0 renames X -> Y, Client 1 deletes X, Client 2 creates Y. " + + "All three operations happen while the other clients are offline. " + + "Tests that the system handles the three-way conflict and converges.", + clients: 3, + steps: [ + { + type: "create", + client: 0, + path: "X.md", + content: "original from A" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "enable-sync", client: 2 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { type: "disable-sync", client: 2 }, + + { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, + + { type: "delete", client: 1, path: "X.md" }, + + { + type: "create", + client: 2, + path: "Y.md", + content: "new from C" + }, + + { type: "enable-sync", client: 0 }, + { type: "sync", client: 0 }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 2 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("X.md").assertAnyFileContains( + "new from C" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts new file mode 100644 index 00000000..70a2fc8c --- /dev/null +++ b/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { + description: + "Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "delete", client: 0, path: "doc.md" }, + { + type: "update", + client: 1, + path: "doc.md", + content: "edited by client 1" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts new file mode 100644 index 00000000..ca53244e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const updateDuringCreateProcessingTest: TestDefinition = { + description: + "Client 0 creates a file while the server is paused, then immediately updates it. After the server resumes, both clients should converge with the updated content.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "file.md", + content: "initial" + }, + + { + type: "update", + client: 0, + path: "file.md", + content: "updated during create" + }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "file.md", + "updated during create" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts b/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts new file mode 100644 index 00000000..ef6cd771 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts @@ -0,0 +1,47 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const userParenthesizedFileNotDeletedTest: TestDefinition = { + description: + "A user-created file named 'Chapter (1).bin' alongside 'Chapter.bin' should not " + + "be mistakenly removed when another client creates a conflicting file.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + + { + type: "create", + client: 0, + path: "Chapter.bin", + content: "chapter one" + }, + { + type: "create", + client: 0, + path: "Chapter (1).bin", + content: "chapter one notes" + }, + + { type: "sync", client: 0 }, + + { + type: "create", + client: 1, + path: "Chapter.bin", + content: "chapter one notes" + }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(3) + .assertFileExists("Chapter.bin") + .assertFileExists("Chapter (1).bin") + .assertFileExists("Chapter (2).bin"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts new file mode 100644 index 00000000..063faff4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts @@ -0,0 +1,35 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const watermarkAdvancesOnSkipTest: TestDefinition = { + description: + "Both clients create the same file offline. After syncing, both disconnect and reconnect. The reconnect should not replay already-processed updates.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { type: "create", client: 0, path: "doc.md", content: "from client 0" }, + { type: "create", client: 1, path: "doc.md", content: "from client 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertFileExists("doc.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts new file mode 100644 index 00000000..ac9ba467 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts @@ -0,0 +1,37 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { + description: + "Client 0 sends two rapid updates. Client 1 processes both, then disconnects and reconnects. Both clients should still converge to the latest content after reconnect.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "update", client: 0, path: "doc.md", content: "update 1" }, + { type: "sync", client: 0 }, + { type: "update", client: 0, path: "doc.md", content: "update 2" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "update 2"); + } + }, + + { type: "disable-sync", client: 1 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "update 2"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/utils/assert.ts b/frontend/deterministic-tests/src/utils/assert.ts new file mode 100644 index 00000000..4e709060 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/assert.ts @@ -0,0 +1,5 @@ +export function assert(value: boolean, message: string): asserts value { + if (!value) { + throw new Error(message); + } +} diff --git a/frontend/deterministic-tests/src/utils/assertable-state.ts b/frontend/deterministic-tests/src/utils/assertable-state.ts new file mode 100644 index 00000000..7c6f192c --- /dev/null +++ b/frontend/deterministic-tests/src/utils/assertable-state.ts @@ -0,0 +1,150 @@ +import type { ClientState } from "../test-definition"; + +export class AssertableState { + public readonly files: Map; + public readonly clientFiles: Map[]; + + public constructor(state: ClientState) { + this.files = state.files; + this.clientFiles = state.clientFiles; + } + + public assertFileCount(expected: number): this { + if (this.files.size !== expected) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error( + `Expected ${expected} file(s), got ${this.files.size}: [${keys}]` + ); + } + return this; + } + + public assertFileExists(path: string): this { + if (!this.files.has(path)) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error(`Expected "${path}" to exist. Files: [${keys}]`); + } + return this; + } + + public assertFileNotExists(path: string): this { + if (this.files.has(path)) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error( + `Expected "${path}" not to exist. Files: [${keys}]` + ); + } + return this; + } + + public assertContent(path: string, expected: string): this { + this.assertFileExists(path); + const actual = this.files.get(path) ?? ""; + if (actual !== expected) { + throw new Error( + `Expected "${path}" to have content "${expected}", got: "${actual}"` + ); + } + return this; + } + + public assertContains(path: string, ...substrings: string[]): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const missing = substrings.filter((s) => !content.includes(s)); + if (missing.length > 0) { + throw new Error( + `Expected "${path}" to contain ${missing.map((s) => `"${s}"`).join(", ")}. Content: "${content}"` + ); + } + return this; + } + + public assertContainsAny(path: string, ...substrings: string[]): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const found = substrings.some((s) => content.includes(s)); + if (!found) { + throw new Error( + `Expected "${path}" to contain at least one of ${substrings.map((s) => `"${s}"`).join(", ")}. Content: "${content}"` + ); + } + return this; + } + + public assertAnyFileContains(...substrings: string[]): this { + const allContent = Array.from(this.files.values()).join("\n"); + const missing = substrings.filter((s) => !allContent.includes(s)); + if (missing.length > 0) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected some file to contain ${missing.map((s) => `"${s}"`).join(", ")}.\nFiles:\n${dump}` + ); + } + return this; + } + + public assertNoFileContains(...substrings: string[]): this { + const offenders: { path: string; substring: string }[] = []; + for (const [path, content] of this.files) { + for (const s of substrings) { + if (content.includes(s)) { + offenders.push({ path, substring: s }); + } + } + } + if (offenders.length > 0) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected no file to contain ${substrings.map((s) => `"${s}"`).join(", ")}, but found ${offenders.map((o) => `"${o.substring}" in "${o.path}"`).join(", ")}.\nFiles:\n${dump}` + ); + } + return this; + } + + public assertSubstringCount( + path: string, + substring: string, + expected: number + ): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const actual = content.split(substring).length - 1; + if (actual !== expected) { + throw new Error( + `Expected "${substring}" to appear ${expected} time(s) in "${path}", found ${actual}. Content: "${content}"` + ); + } + return this; + } + + public assertContentInAtMostOneFile(substring: string): this { + const matches = Array.from(this.files.entries()).filter(([, content]) => + content.includes(substring) + ); + if (matches.length > 1) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected "${substring}" in at most 1 file, found in ${matches.length}: [${matches.map(([p]) => p).join(", ")}].\nFiles:\n${dump}` + ); + } + return this; + } + + public ifFileExists(path: string, fn: (state: this) => void): this { + if (this.files.has(path)) { + fn(this); + } + return this; + } + + public getContent(path: string): string { + return this.files.get(path) ?? ""; + } +} diff --git a/frontend/deterministic-tests/src/utils/find-free-port.ts b/frontend/deterministic-tests/src/utils/find-free-port.ts new file mode 100644 index 00000000..0734c1a9 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/find-free-port.ts @@ -0,0 +1,29 @@ +import * as net from "node:net"; + +interface PortReservation { + port: number; + release: () => void; +} + +/** + * Find a free port and keep it reserved until the caller explicitly releases it. + */ +export async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (addr === null || typeof addr === "string") { + server.close(); + reject(new Error("Failed to get port from server")); + return; + } + const { port } = addr; + resolve({ + port, + release: () => server.close() + }); + }); + server.on("error", reject); + }); +} diff --git a/frontend/deterministic-tests/src/utils/sleep.ts b/frontend/deterministic-tests/src/utils/sleep.ts new file mode 100644 index 00000000..ff474799 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/sleep.ts @@ -0,0 +1,3 @@ +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/deterministic-tests/src/utils/with-timeout.ts b/frontend/deterministic-tests/src/utils/with-timeout.ts new file mode 100644 index 00000000..14ee3f27 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/with-timeout.ts @@ -0,0 +1,15 @@ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + message: string +): Promise { + let timeoutId: ReturnType | undefined = undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(message)); + }, timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timeoutId); + }); +} diff --git a/frontend/deterministic-tests/tsconfig.json b/frontend/deterministic-tests/tsconfig.json new file mode 100644 index 00000000..7558871d --- /dev/null +++ b/frontend/deterministic-tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "strict": true, + "target": "ES2022", + "module": "CommonJS", + "esModuleInterop": true, + "lib": ["DOM", "ES2024"], + "moduleResolution": "node" + }, + "exclude": ["./dist"] +} diff --git a/frontend/deterministic-tests/webpack.config.js b/frontend/deterministic-tests/webpack.config.js new file mode 100644 index 00000000..6aee1547 --- /dev/null +++ b/frontend/deterministic-tests/webpack.config.js @@ -0,0 +1,30 @@ +const path = require("path"); +const webpack = require("webpack"); + +module.exports = { + entry: "./src/cli.ts", + target: "node", + mode: "production", + optimization: { + minimize: false + }, + module: { + rules: [ + { + test: /\.ts$/, + use: "ts-loader" + } + ] + }, + resolve: { + extensions: [".ts", ".js"] + }, + output: { + globalObject: "this", + filename: "cli.js", + path: path.resolve(__dirname, "dist") + }, + plugins: [ + new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true }) + ] +}; diff --git a/frontend/package.json b/frontend/package.json index df167a5e..0dd9057d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "sync-client", "obsidian-plugin", "test-client", + "deterministic-tests", "local-client-cli" ], "prettier": { @@ -17,7 +18,7 @@ "build": "npm run build --workspaces", "dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"", "test": "npm run test --workspaces", - "lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"", + "lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"", "update": "ncu -u -ws" }, "devDependencies": { From 40fbd42b92cf78581b7cc848cfb1f93423e21e9b Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 11:17:21 +0100 Subject: [PATCH 15/19] Remove GH actions (#192) Reviewed-on: https://home.schmelczer.dev/git/git/andras/vault-link/pulls/192 Co-authored-by: Andras Schmelczer Co-committed-by: Andras Schmelczer --- .github/dependabot.yml | 27 ------ .github/workflows/check.yml | 36 -------- .github/workflows/deploy-docs.yml | 58 ------------- .github/workflows/e2e.yml | 72 ---------------- .github/workflows/publish-cli-docker.yml | 67 --------------- .github/workflows/publish-plugin.yml | 59 ------------- .github/workflows/publish-server-docker.yml | 92 --------------------- 7 files changed, 411 deletions(-) delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/check.yml delete mode 100644 .github/workflows/deploy-docs.yml delete mode 100644 .github/workflows/e2e.yml delete mode 100644 .github/workflows/publish-cli-docker.yml delete mode 100644 .github/workflows/publish-plugin.yml delete mode 100644 .github/workflows/publish-server-docker.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 7d56669b..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,27 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "npm" - directories: ["/frontend", "/docs"] - schedule: - interval: "daily" - - - package-ecosystem: "docker" - directories: ["**"] - schedule: - interval: "daily" - - - package-ecosystem: "cargo" - directories: ["**"] - schedule: - interval: "daily" - - # Disable this for security reasons - # - package-ecosystem: "github-actions" - # directories: ["**"] - # schedule: - # interval: "daily" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index fc1b1c99..00000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Check - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: "-Dwarnings" - -jobs: - build: - runs-on: self-hosted - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "25.x" - check-latest: true - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.92.0" - components: clippy, rustfmt - - - name: Lint & test - run: scripts/check.sh diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index bb25e463..00000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Deploy Documentation - -on: - push: - branches: - - main - paths: - - "docs/**" - - ".github/workflows/deploy-docs.yml" - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - build: - runs-on: self-hosted - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "25.x" - check-latest: true - - - name: Setup Pages - uses: actions/configure-pages@v4 - - - name: Build docs - run: scripts/build-docs.sh - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: docs/.vitepress/dist - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - needs: build - runs-on: self-hosted - name: Deploy - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 98dbfc1f..00000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: E2E tests - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - schedule: - - cron: "0 * * * *" - workflow_dispatch: - -concurrency: - group: e2e-tests - cancel-in-progress: false - -env: - RUSTFLAGS: "-Dwarnings" - -jobs: - build: - runs-on: self-hosted - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "25.x" - check-latest: true - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.92.0" - components: clippy, rustfmt - - - name: Setup rust - run: | - which sqlx || cargo install sqlx-cli - cd sync-server - sqlx database create --database-url sqlite://db.sqlite3 - sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 - - - name: E2E tests - run: | - cd sync-server - cargo run config-e2e.yml --color never & - SERVER_PID=$! - cd .. - - scripts/e2e.sh 8 - EXIT_CODE=$? - - kill $SERVER_PID 2>/dev/null || true - wait $SERVER_PID 2>/dev/null || true - - exit $EXIT_CODE - - - name: Upload e2e logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: e2e-logs - path: logs/ - retention-days: 30 - - - name: Cleanup - if: always() - run: scripts/clean-up.sh diff --git a/.github/workflows/publish-cli-docker.yml b/.github/workflows/publish-cli-docker.yml deleted file mode 100644 index 10a7e8ba..00000000 --- a/.github/workflows/publish-cli-docker.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Publish CLI - -on: - push: - branches: ["main"] - tags: ["*"] - pull_request: - branches: ["main"] - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }}-cli - -jobs: - publish-docker: - runs-on: self-hosted - - permissions: - contents: read - packages: write - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install cosign - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 - with: - cosign-release: "v2.2.4" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - - - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Build and push Docker image - id: build-and-push - uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 - with: - context: frontend - file: frontend/local-client-cli/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Sign the published Docker image - env: - TAGS: ${{ steps.meta.outputs.tags }} - DIGEST: ${{ steps.build-and-push.outputs.digest }} - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml deleted file mode 100644 index 452bc601..00000000 --- a/.github/workflows/publish-plugin.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Publish Obsidian plugin - -on: - push: - tags: ["*"] - -env: - CARGO_TERM_COLOR: always - -jobs: - publish-plugin: - runs-on: self-hosted - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "25.x" - check-latest: true - - - name: Build plugin - run: | - cd frontend - npm ci - npm run build - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.92.0" - components: clippy, rustfmt - - - name: Install cross-compilation tools - run: | - apt update - apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 - - - name: Build Linux and Windows binaries - run: ./scripts/build-sync-server-binaries.sh - - - name: Create release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - tag="${GITHUB_REF#refs/tags/}" - - mkdir -p release - cp frontend/obsidian-plugin/dist/* release/ - cp sync-server/artifacts/sync-server-* release/ - cd release - - gh release create "$tag" \ - --title="$tag" \ - --draft \ - * diff --git a/.github/workflows/publish-server-docker.yml b/.github/workflows/publish-server-docker.yml deleted file mode 100644 index 4a97a9e6..00000000 --- a/.github/workflows/publish-server-docker.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Publish server Docker image - -on: - push: - branches: ["main"] - tags: ["*"] - pull_request: - branches: ["main"] - -env: - # Use docker.io for Docker Hub if empty - REGISTRY: ghcr.io - # github.repository as / - IMAGE_NAME: ${{ github.repository }} - -jobs: - publish-docker: - runs-on: self-hosted - - permissions: - contents: read - packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio. - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # Install the cosign tool - # https://github.com/sigstore/cosign-installer - - name: Install cosign - if: github.ref_type == 'tag' - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 - with: - cosign-release: "v2.2.4" - - # Set up BuildKit Docker container builder to be able to build - # multi-platform images and export cache - # https://github.com/docker/setup-buildx-action - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - - # Login against a Docker registry - # https://github.com/docker/login-action - - name: Log into registry ${{ env.REGISTRY }} - if: github.ref_type == 'tag' - uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - # Build and push Docker image with Buildx - # https://github.com/docker/build-push-action - - name: Build and push Docker image - id: build-and-push - uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 - with: - context: sync-server - platforms: linux/amd64,linux/arm64 - push: ${{ github.ref_type == 'tag' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # Sign the resulting Docker image digest. - # This will only write to the public Rekor transparency log when the Docker - # repository is public to avoid leaking data. If you would like to publish - # transparency data even for private images, pass --force to cosign below. - # https://github.com/sigstore/cosign - - name: Sign the published Docker image - if: ${{ github.ref_type == 'tag' }} - env: - # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable - TAGS: ${{ steps.meta.outputs.tags }} - DIGEST: ${{ steps.build-and-push.outputs.digest }} - # This step uses the identity token to provision an ephemeral certificate - # against the sigstore community Fulcio instance. - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} From 682dc7449722ebee2ce08365dc0ab6dfaad28f33 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 13:41:51 +0100 Subject: [PATCH 16/19] Update local-client-cli and obsidian-plugin Pulls the local-client-cli and obsidian-plugin changes from asch/fix-everything onto a fresh branch off main. --- frontend/local-client-cli/Dockerfile | 4 +- frontend/local-client-cli/README.md | 50 ++-- frontend/local-client-cli/package.json | 16 +- frontend/local-client-cli/src/args.test.ts | 226 +++++++++++++++++- frontend/local-client-cli/src/args.ts | 183 ++++++++++---- frontend/local-client-cli/src/cli.ts | 174 ++++++++------ frontend/local-client-cli/src/file-watcher.ts | 72 ++---- frontend/local-client-cli/src/healthcheck.ts | 1 + .../src/logger-formatter.test.ts | 50 ++++ .../local-client-cli/src/logger-formatter.ts | 19 +- .../local-client-cli/src/node-filesystem.ts | 90 +++---- .../local-client-cli/src/path-utils.test.ts | 60 +++++ frontend/local-client-cli/src/path-utils.ts | 15 ++ frontend/local-client-cli/tsconfig.json | 4 +- frontend/local-client-cli/webpack.config.js | 54 ++--- frontend/obsidian-plugin/README.md | 26 +- frontend/obsidian-plugin/package.json | 22 +- .../obsidian-plugin/src/vault-link-plugin.ts | 32 ++- .../src/views/cursors/file-explorer.ts | 6 +- .../views/cursors/remote-cursors-plugin.ts | 5 +- .../src/views/settings/settings-tab.ts | 55 +---- .../status-description/status-description.ts | 2 +- frontend/obsidian-plugin/tsconfig.json | 9 +- frontend/obsidian-plugin/webpack.config.js | 2 +- 24 files changed, 741 insertions(+), 436 deletions(-) create mode 100644 frontend/local-client-cli/src/logger-formatter.test.ts create mode 100644 frontend/local-client-cli/src/path-utils.test.ts create mode 100644 frontend/local-client-cli/src/path-utils.ts diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile index 695ab587..0dfa7055 100644 --- a/frontend/local-client-cli/Dockerfile +++ b/frontend/local-client-cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-slim AS builder +FROM node:25-slim AS builder WORKDIR /build @@ -7,7 +7,7 @@ COPY . . RUN npm ci RUN npm run build -FROM node:22-alpine +FROM node:25-alpine LABEL org.opencontainers.image.title="VaultLink Local CLI" LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client" diff --git a/frontend/local-client-cli/README.md b/frontend/local-client-cli/README.md index 0585bacc..e91322f9 100644 --- a/frontend/local-client-cli/README.md +++ b/frontend/local-client-cli/README.md @@ -47,24 +47,25 @@ vaultlink \ ### Required -| Option | Description | -|--------|-------------| -| `-l, --local-path ` | Local directory to sync | -| `-r, --remote-uri ` | Remote server WebSocket URI (ws:// or wss://) | -| `-t, --token ` | Authentication token | -| `-v, --vault-name ` | Vault name on server | +| Option | Description | +| ------------------------- | --------------------------------------------- | +| `-l, --local-path ` | Local directory to sync | +| `-r, --remote-uri ` | Remote server WebSocket URI (ws:// or wss://) | +| `-t, --token ` | Authentication token | +| `-v, --vault-name ` | Vault name on server | ### Optional -| Option | Default | Description | -|--------|---------|-------------| -| `--sync-concurrency ` | `1` | Concurrent sync operations | -| `--max-file-size-mb ` | `10` | Maximum file size in MB | -| `--ignore-pattern ` | - | Glob pattern to ignore (repeatable) | -| `--websocket-retry-interval-ms ` | `3500` | WebSocket reconnection interval | -| `--log-level ` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | -| `-h, --help` | - | Show help | -| `-V, --version` | - | Show version | +| Option | Default | Description | +| ------------------------------------ | ------- | ----------------------------------------------- | +| `--max-file-size-mb ` | `10` | Maximum file size in MB | +| `--ignore-pattern ` | - | Glob pattern to ignore (repeatable) | +| `--websocket-retry-interval-ms ` | `3500` | WebSocket reconnection interval | +| `--log-level ` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | +| `--line-endings ` | `auto` | Line ending style: auto, lf, crlf | +| `-q, --quiet` | - | Suppress startup banner for non-interactive use | +| `-h, --help` | - | Show help | +| `-V, --version` | - | Show version | ### Auto-Ignored Patterns @@ -74,22 +75,32 @@ vaultlink \ ### Examples Basic usage: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default ``` With ignore patterns: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ - --ignore-pattern "*.tmp" \ + --ignore-pattern "**/*.tmp" \ --ignore-pattern ".DS_Store" \ --ignore-pattern "node_modules/**" ``` -With debug logging: +With debug logging and quiet startup: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ - --log-level DEBUG + --log-level DEBUG --quiet +``` + +Force LF line endings (useful for cross-platform vaults): + +```bash +vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ + --line-endings lf ``` ## Docker Deployment @@ -176,6 +187,7 @@ services: ## Development Build: + ```bash npm run build # or from the parent folder, run @@ -183,11 +195,13 @@ docker build -f local-client-cli/Dockerfile . ``` Test: + ```bash npm test ``` Docker build: + ```bash cd frontend docker build -f local-client-cli/Dockerfile -t vault-link-cli:test . diff --git a/frontend/local-client-cli/package.json b/frontend/local-client-cli/package.json index cade4990..a862b297 100644 --- a/frontend/local-client-cli/package.json +++ b/frontend/local-client-cli/package.json @@ -11,18 +11,16 @@ "build": "webpack --mode production", "test": "tsx --test 'src/**/*.test.ts'" }, - "dependencies": { - "commander": "^14.0.2", - "watcher": "^2.3.1" - }, "devDependencies": { - "@types/node": "^24.8.1", + "commander": "^14.0.2", + "watcher": "^2.3.1", + "@types/node": "^25.0.2", "sync-client": "file:../sync-client", - "ts-loader": "^9.5.2", + "ts-loader": "^9.5.4", "tslib": "2.8.1", - "tsx": "^4.20.6", - "typescript": "5.8.3", - "webpack": "^5.99.9", + "tsx": "^4.21.0", + "typescript": "5.9.3", + "webpack": "^5.103.0", "webpack-cli": "^6.0.1" } } diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts index eb195538..c075d193 100644 --- a/frontend/local-client-cli/src/args.test.ts +++ b/frontend/local-client-cli/src/args.test.ts @@ -55,13 +55,10 @@ test("parseArgs - parse with optional arguments", () => { "mytoken", "-v", "default", - "--sync-concurrency", - "5", "--max-file-size-mb", "20" ]); - assert.equal(args.syncConcurrency, 5); assert.equal(args.maxFileSizeMB, 20); }); @@ -228,3 +225,226 @@ test("parseArgs - throws on invalid log level", () => { ]); }, /Invalid log level/); }); + +test("parseArgs - reads required options from environment variables", () => { + process.env.VAULTLINK_LOCAL_PATH = "/env/path"; + process.env.VAULTLINK_REMOTE_URI = "https://env.example.com"; + process.env.VAULTLINK_TOKEN = "env-token"; + process.env.VAULTLINK_VAULT_NAME = "env-vault"; + + try { + const args = parseArgs(["node", "cli.js"]); + assert.equal(args.localPath, "/env/path"); + assert.equal(args.remoteUri, "https://env.example.com"); + assert.equal(args.token, "env-token"); + assert.equal(args.vaultName, "env-vault"); + } finally { + delete process.env.VAULTLINK_LOCAL_PATH; + delete process.env.VAULTLINK_REMOTE_URI; + delete process.env.VAULTLINK_TOKEN; + delete process.env.VAULTLINK_VAULT_NAME; + } +}); + +test("parseArgs - CLI arguments take precedence over environment variables", () => { + process.env.VAULTLINK_TOKEN = "env-token"; + + try { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "cli-token", + "-v", + "default" + ]); + assert.equal(args.token, "cli-token"); + } finally { + delete process.env.VAULTLINK_TOKEN; + } +}); + +test("parseArgs - reads log level from environment variable", () => { + process.env.VAULTLINK_LOG_LEVEL = "DEBUG"; + + try { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + assert.equal(args.logLevel, LogLevel.DEBUG); + } finally { + delete process.env.VAULTLINK_LOG_LEVEL; + } +}); + +test("parseArgs - quiet defaults to false", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + + assert.equal(args.quiet, false); +}); + +test("parseArgs - parse --quiet flag", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--quiet" + ]); + + assert.equal(args.quiet, true); +}); + +test("parseArgs - parse -q short flag", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "-q" + ]); + + assert.equal(args.quiet, true); +}); + +test("parseArgs - line-endings defaults to auto", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + + assert.equal(args.lineEndings, "auto"); +}); + +test("parseArgs - parse --line-endings lf", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--line-endings", + "lf" + ]); + + assert.equal(args.lineEndings, "lf"); +}); + +test("parseArgs - parse --line-endings crlf", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--line-endings", + "crlf" + ]); + + assert.equal(args.lineEndings, "crlf"); +}); + +test("parseArgs - throws on invalid remote URI protocol", () => { + assert.throws(() => { + parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "ftp://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + }, /Invalid remote URI/); +}); + +test("parseArgs - accepts http:// remote URI", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "http://localhost:3000", + "-t", + "mytoken", + "-v", + "default" + ]); + + assert.equal(args.remoteUri, "http://localhost:3000"); +}); + +test("parseArgs - accepts wss:// remote URI", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "wss://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + + assert.equal(args.remoteUri, "wss://sync.example.com"); +}); diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 615b9d71..442c4817 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -1,21 +1,26 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import packageJson from "../package.json"; import { LogLevel } from "sync-client"; -export interface CliArgs { +type LineEndingMode = "auto" | "lf" | "crlf"; + +interface CliArgs { remoteUri: string; token: string; vaultName: string; localPath: string; - syncConcurrency?: number; maxFileSizeMB?: number; ignorePatterns?: string[]; webSocketRetryIntervalMs?: number; logLevel: LogLevel; health?: string; enableTelemetry?: boolean; + quiet: boolean; + lineEndings: LineEndingMode; } +const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"]; + export function parseArgs(argv: string[]): CliArgs { const program = new Command(); @@ -25,41 +30,83 @@ export function parseArgs(argv: string[]): CliArgs { "VaultLink Local CLI - Sync your vault to the local filesystem" ) .version(packageJson.version) - .option("-l, --local-path ", "Local directory path to sync") - .option("-r, --remote-uri ", "Remote server URI") - .option("-t, --token ", "Authentication token") - .option("-v, --vault-name ", "Vault name") - .option( - "--sync-concurrency ", - "[OPTIONAL] Number of concurrent sync operations", - parseInt + .addOption( + new Option( + "-l, --local-path ", + "Local directory path to sync" + ).env("VAULTLINK_LOCAL_PATH") ) - .option( - "--max-file-size-mb ", - "[OPTIONAL] Maximum file size in MB", - parseInt + .addOption( + new Option("-r, --remote-uri ", "Remote server URI").env( + "VAULTLINK_REMOTE_URI" + ) ) - .option( - "--ignore-pattern ", - "[OPTIONAL] Patterns to ignore (can be specified multiple times)" + .addOption( + new Option("-t, --token ", "Authentication token").env( + "VAULTLINK_TOKEN" + ) ) - .option( - "--websocket-retry-interval-ms ", - "[OPTIONAL] WebSocket retry interval in milliseconds", - parseInt + .addOption( + new Option("-v, --vault-name ", "Vault name").env( + "VAULTLINK_VAULT_NAME" + ) ) - .option( - "--log-level ", - "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", - "INFO" + .addOption( + new Option( + "--max-file-size-mb ", + "[OPTIONAL] Maximum file size in MB" + ) + .argParser(parseInt) + .env("VAULTLINK_MAX_FILE_SIZE_MB") ) - .option( - "--health ", - "[OPTIONAL] Path to health status file for Docker healthcheck" + .addOption( + new Option( + "--ignore-pattern ", + "[OPTIONAL] Patterns to ignore (can be specified multiple times)" + ).env("VAULTLINK_IGNORE_PATTERNS") ) - .option( - "--enable-telemetry", - "[OPTIONAL] Enable telemetry (disabled by default)" + .addOption( + new Option( + "--websocket-retry-interval-ms ", + "[OPTIONAL] WebSocket retry interval in milliseconds" + ) + .argParser(parseInt) + .env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS") + ) + .addOption( + new Option( + "--log-level ", + "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)" + ) + .default("INFO") + .env("VAULTLINK_LOG_LEVEL") + ) + .addOption( + new Option( + "--health ", + "[OPTIONAL] Path to health status file for Docker healthcheck" + ).env("VAULTLINK_HEALTH") + ) + .addOption( + new Option( + "--enable-telemetry", + "[OPTIONAL] Enable telemetry (disabled by default)" + ).env("VAULTLINK_ENABLE_TELEMETRY") + ) + .addOption( + new Option( + "-q, --quiet", + "[OPTIONAL] Suppress startup banner for non-interactive use" + ).env("VAULTLINK_QUIET") + ) + .addOption( + new Option( + "--line-endings ", + "[OPTIONAL] Line ending style: auto (platform default), lf, crlf" + ) + .default("auto") + .choices(["auto", "lf", "crlf"]) + .env("VAULTLINK_LINE_ENDINGS") ) .addHelpText( "after", @@ -67,9 +114,13 @@ export function parseArgs(argv: string[]): CliArgs { Examples: $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ - --ignore-pattern ".git/**" --ignore-pattern "*.tmp" + --ignore-pattern ".git/**" --ignore-pattern "**/*.tmp" $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ - --log-level DEBUG + --log-level DEBUG --quiet + +Environment variables: + All options can be configured via VAULTLINK_ prefixed environment variables. + CLI arguments take precedence over environment variables. ` ); @@ -81,7 +132,6 @@ Examples: const remoteUri = opts.remoteUri as string | undefined; const token = opts.token as string | undefined; const vaultName = opts.vaultName as string | undefined; - const syncConcurrency = opts.syncConcurrency as number | undefined; const maxFileSizeMb = opts.maxFileSizeMb as number | undefined; const ignorePattern = opts.ignorePattern as string[] | undefined; const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as @@ -90,22 +140,39 @@ Examples: const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; const health = opts.health as string | undefined; const enableTelemetry = opts.enableTelemetry as boolean | undefined; + const quiet = (opts.quiet as boolean | undefined) ?? false; + const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto"; /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ - if (localPath === undefined) { + const requireOption = (value: T | undefined, name: string): T => { + if (value === undefined) { + const option = program.options.find( + (o) => o.attributeName() === name + ); + const envHint = + option?.envVar !== undefined + ? ` (or set ${option.envVar})` + : ""; + throw new Error( + `required option '${option?.flags ?? name}' not specified${envHint}` + ); + } + return value; + }; + + const requiredLocalPath = requireOption(localPath, "localPath"); + const requiredRemoteUri = requireOption(remoteUri, "remoteUri"); + const requiredToken = requireOption(token, "token"); + const requiredVaultName = requireOption(vaultName, "vaultName"); + + // Validate remote URI protocol + if ( + !VALID_PROTOCOLS.some((prefix) => requiredRemoteUri.startsWith(prefix)) + ) { throw new Error( - "required option '-l, --local-path ' not specified" + `Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}` ); } - if (remoteUri === undefined) { - throw new Error("required option '--remote-uri ' not specified"); - } - if (token === undefined) { - throw new Error("required option '--token ' not specified"); - } - if (vaultName === undefined) { - throw new Error("required option '--vault-name ' not specified"); - } // Validate and parse log level const logLevelUpper = logLevelStr.toUpperCase(); @@ -120,17 +187,29 @@ Examples: } const logLevel = logLevelUpper; + const validLineEndings: readonly string[] = ["auto", "lf", "crlf"]; + const isLineEndingMode = (value: string): value is LineEndingMode => { + return validLineEndings.includes(value); + }; + if (!isLineEndingMode(lineEndingsStr)) { + throw new Error( + `Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${validLineEndings.join(", ")}` + ); + } + const lineEndings = lineEndingsStr; + return { - localPath, - remoteUri, - token, - vaultName, - syncConcurrency, + localPath: requiredLocalPath, + remoteUri: requiredRemoteUri, + token: requiredToken, + vaultName: requiredVaultName, maxFileSizeMB: maxFileSizeMb, ignorePatterns: ignorePattern, webSocketRetryIntervalMs: websocketRetryIntervalMs, logLevel, health, - enableTelemetry + enableTelemetry, + quiet, + lineEndings }; } diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 48fd8954..e06fda47 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -5,24 +5,27 @@ import type { NetworkConnectionStatus } from "sync-client"; import { SyncClient, DEFAULT_SETTINGS, + Logger, LogLevel, + type LogLine, type SyncSettings, type StoredDatabase } from "sync-client"; import { parseArgs } from "./args"; import { NodeFileSystemOperations } from "./node-filesystem"; import { FileWatcher } from "./file-watcher"; -import { formatLogLine, colorize, styleText } from "./logger-formatter"; +import { formatLogLine } from "./logger-formatter"; import packageJson from "../package.json"; function writeHealthStatus( + logger: Logger, filePath: string, connectionStatus: NetworkConnectionStatus ): void { try { fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus)); } catch (error) { - console.error( + logger.error( `Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}` ); } @@ -35,12 +38,37 @@ const LOG_LEVEL_ORDER = { [LogLevel.ERROR]: 3 }; +function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void { + return (logLine: LogLine): void => { + if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[minLevel]) { + // eslint-disable-next-line no-console + console.log(formatLogLine(logLine)); + } + }; +} + const HEALTH_CHECK_INTERVAL_MS = 30 * 1000; +const PROGRESS_LOG_INTERVAL_MS = 2000; + +function resolveLineEndings(mode: "auto" | "lf" | "crlf"): string { + switch (mode) { + case "lf": + return "\n"; + case "crlf": + return "\r\n"; + case "auto": + return process.platform === "win32" ? "\r\n" : "\n"; + } +} async function main(): Promise { const args = parseArgs(process.argv); const absolutePath = path.resolve(args.localPath); + const logger = new Logger(); + const logHandler = createLogHandler(args.logLevel); + logger.onLogEmitted.add(logHandler); + if (!fsSync.existsSync(absolutePath)) { fsSync.mkdirSync(absolutePath, { recursive: true }); } @@ -48,36 +76,25 @@ async function main(): Promise { try { const stats = await fs.stat(absolutePath); if (!stats.isDirectory()) { - console.error( - colorize(`Error: ${absolutePath} is not a directory`, "red") - ); + logger.error(`${absolutePath} is not a directory`); process.exit(1); } } catch (error) { - console.error( - colorize( - `Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) + logger.error( + `Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); } - console.log( - styleText("VaultLink Local CLI", "bold", "cyan") + - colorize(` v${packageJson.version}`, "dim") - ); - console.log(colorize("=".repeat(50), "dim")); - console.log( - `${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}` - ); - console.log( - `${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}` - ); - console.log( - `${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}` - ); - console.log(""); + if (!args.quiet) { + logger.info(`VaultLink Local CLI v${packageJson.version}`); + logger.info(`Local path: ${absolutePath}`); + logger.info(`Remote URI: ${args.remoteUri}`); + logger.info(`Vault name: ${args.vaultName}`); + if (args.lineEndings !== "auto") { + logger.info(`Line endings: ${args.lineEndings.toUpperCase()}`); + } + } const dataDir = path.join(absolutePath, ".vaultlink"); const dataFile = path.join(dataDir, "sync-data.json"); @@ -97,8 +114,6 @@ async function main(): Promise { remoteUri: args.remoteUri, token: args.token, vaultName: args.vaultName, - syncConcurrency: - args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency, maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, ignorePatterns, webSocketRetryIntervalMs: @@ -119,12 +134,7 @@ async function main(): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion database = JSON.parse(content) as Partial; } catch { - console.error( - colorize( - `Cannot read data file at ${dataFile}`, - "yellow" - ) - ); + logger.warn(`Cannot read data file at ${dataFile}`); } return { @@ -133,23 +143,27 @@ async function main(): Promise { }; }, save: async ({ database: persistedDatabase }) => { - // settings can't be updated when running with this CLI await fs.writeFile( dataFile, JSON.stringify(persistedDatabase, null, 2) ); } }, - nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" + nativeLineEndings: resolveLineEndings(args.lineEndings) }); if (args.health !== undefined) { const healthFile = args.health; - const healthInterval = setInterval(() => { + const writeHealth = (): void => { void client.checkConnection().then((status) => { - writeHealthStatus(healthFile, status); + writeHealthStatus(client.logger, healthFile, status); }); - }, HEALTH_CHECK_INTERVAL_MS); + }; + writeHealth(); + const healthInterval = setInterval( + writeHealth, + HEALTH_CHECK_INTERVAL_MS + ); const clearHealthInterval = (): void => { clearInterval(healthInterval); }; @@ -158,17 +172,10 @@ async function main(): Promise { process.on("exit", clearHealthInterval); } - // Add colored log formatter with level filtering - client.logger.onLogEmitted.add((logLine) => { - // Only show messages at or above the configured log level - if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) { - console.log(formatLogLine(logLine)); - } - }); - + client.logger.onLogEmitted.add(logHandler); client.logger.info("Starting sync client"); - const fileWatcher = new FileWatcher(absolutePath, client); + const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns); client.onWebSocketStatusChanged.add(() => { const isConnected = client.isWebSocketConnected; @@ -177,26 +184,54 @@ async function main(): Promise { ); }); + let syncBatchSize = 0; + let totalSyncOps = 0; + let lastProgressLogTime = 0; + client.onRemainingOperationsCountChanged.add((remaining) => { + if (remaining > syncBatchSize) { + syncBatchSize = remaining; + } + if (remaining === 0) { - client.logger.info("All sync operations completed"); + if (syncBatchSize > 0) { + totalSyncOps += syncBatchSize; + client.logger.info( + `Sync batch complete (${syncBatchSize} operations)` + ); + syncBatchSize = 0; + } } else { - client.logger.info(`${remaining} sync operations remaining`); + const now = Date.now(); + if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) { + client.logger.info( + `Syncing: ${remaining} operations remaining` + ); + lastProgressLogTime = now; + } } }); + let isShuttingDown = false; const gracefulShutdown = async (signal: string): Promise => { - console.log( - colorize( - `\n${signal} received. Shutting down gracefully...`, - "yellow" - ) - ); + if (isShuttingDown) { + return; + } + isShuttingDown = true; + + client.logger.info(`${signal} received, shutting down gracefully`); fileWatcher.stop(); await client.waitUntilFinished(); await client.destroy(); - console.log(colorize("Shutdown complete", "green")); + + if (totalSyncOps > 0) { + client.logger.info( + `Shutdown complete (${totalSyncOps} operations synced)` + ); + } else { + client.logger.info("Shutdown complete"); + } process.exit(0); }; @@ -210,27 +245,21 @@ async function main(): Promise { try { const connectionStatus = await client.checkConnection(); if (!connectionStatus.isSuccessful) { - console.error( - colorize( - `Error: Cannot connect to server: ${connectionStatus.serverMessage}`, - "red" - ) + client.logger.error( + `Cannot connect to server: ${connectionStatus.serverMessage}` ); process.exit(1); } - console.log(`${colorize("✓", "green")} Server connection successful`); - console.log(colorize("Press Ctrl+C to stop", "dim")); - console.log(""); + if (!args.quiet) { + client.logger.info("Server connection successful"); + } await client.start(); fileWatcher.start(); } catch (error) { - console.error( - colorize( - `Fatal error: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) + client.logger.error( + `Fatal error: ${error instanceof Error ? error.message : String(error)}` ); fileWatcher.stop(); @@ -240,11 +269,10 @@ async function main(): Promise { } main().catch((error: unknown) => { + // Last-resort handler before the logger exists + // eslint-disable-next-line no-console console.error( - colorize( - `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) + `Unexpected error: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); }); diff --git a/frontend/local-client-cli/src/file-watcher.ts b/frontend/local-client-cli/src/file-watcher.ts index e781d18f..c273a412 100644 --- a/frontend/local-client-cli/src/file-watcher.ts +++ b/frontend/local-client-cli/src/file-watcher.ts @@ -1,15 +1,20 @@ import Watcher from "watcher"; import * as path from "path"; import type { SyncClient, RelativePath } from "sync-client"; +import { toUnixPath, matchesGlob } from "./path-utils"; export class FileWatcher { private watcher: Watcher | undefined; private isRunning = false; + private readonly ignorePatterns: string[]; public constructor( private readonly basePath: string, - private readonly client: SyncClient - ) {} + private readonly client: SyncClient, + ignorePatterns: string[] = [] + ) { + this.ignorePatterns = ignorePatterns; + } public start(): void { if (this.isRunning) { @@ -22,7 +27,8 @@ export class FileWatcher { recursive: true, renameDetection: true, renameTimeout: 125, - ignoreInitial: true + ignoreInitial: true, + ignore: (filePath: string): boolean => this.shouldIgnore(filePath) }); this.watcher.on("add", (filePath: string) => { @@ -56,66 +62,32 @@ export class FileWatcher { this.client.logger.info("File watcher stopped"); } + private shouldIgnore(filePath: string): boolean { + const rel = toUnixPath(path.relative(this.basePath, filePath)); + return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern)); + } + private handleCreate(relativePath: RelativePath): void { - this.client - .syncLocallyCreatedFile(relativePath) - .catch((err: unknown) => { - this.client.logger.error( - `Failed to sync created file ${relativePath}: ${this.formatError(err)}` - ); - }); + this.client.syncLocallyCreatedFile(relativePath); } private handleChange(relativePath: RelativePath): void { - this.client - .syncLocallyUpdatedFile({ relativePath }) - .catch((err: unknown) => { - this.client.logger.error( - `Failed to sync updated file ${relativePath}: ${this.formatError(err)}` - ); - }); + this.client.syncLocallyUpdatedFile({ relativePath }); } private handleDelete(relativePath: RelativePath): void { - this.client - .syncLocallyDeletedFile(relativePath) - .catch((err: unknown) => { - this.client.logger.error( - `Failed to sync deleted file ${relativePath}: ${this.formatError(err)}` - ); - }); + this.client.syncLocallyDeletedFile(relativePath); } private handleRename(oldPath: RelativePath, newPath: RelativePath): void { this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`); - this.client - .syncLocallyUpdatedFile({ - oldPath, - relativePath: newPath - }) - .catch((err: unknown) => { - this.client.logger.error( - `Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}` - ); - }); + this.client.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }); } private toRelativePath(absolutePath: string): RelativePath { - const relative = path.relative(this.basePath, absolutePath); - return this.toUnixPath(relative); - } - - /** - * Convert a native platform path to forward slashes - */ - private toUnixPath(nativePath: string): string { - if (path.sep === "\\") { - return nativePath.replace(/\\/g, "/"); - } - return nativePath; - } - - private formatError(err: unknown): string { - return err instanceof Error ? err.message : String(err); + return toUnixPath(path.relative(this.basePath, absolutePath)); } } diff --git a/frontend/local-client-cli/src/healthcheck.ts b/frontend/local-client-cli/src/healthcheck.ts index 2dd9e721..d7211c88 100644 --- a/frontend/local-client-cli/src/healthcheck.ts +++ b/frontend/local-client-cli/src/healthcheck.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* eslint-disable no-console */ /** * Healthcheck script for Docker container diff --git a/frontend/local-client-cli/src/logger-formatter.test.ts b/frontend/local-client-cli/src/logger-formatter.test.ts new file mode 100644 index 00000000..f3078242 --- /dev/null +++ b/frontend/local-client-cli/src/logger-formatter.test.ts @@ -0,0 +1,50 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; +import { formatLogLine } from "./logger-formatter"; +import { LogLevel } from "sync-client"; + +test("formatLogLine - includes level and message", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.INFO, + message: "Test message" + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("INFO")); + assert.ok(result.includes("Test message")); +}); + +test("formatLogLine - ERROR level messages contain bold escape", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.ERROR, + message: "Error occurred" + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("\x1b[1m")); +}); + +test("formatLogLine - highlights file paths in quotes", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.INFO, + message: 'Syncing "notes/test.md"' + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("\x1b[35m")); +}); + +test("formatLogLine - highlights standalone numbers but not numbers in versions", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.INFO, + message: "Listed 42 files from v1.2.3" + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("\x1b[36m42\x1b[0m")); + assert.ok(!result.includes("\x1b[36m1\x1b[0m.")); +}); diff --git a/frontend/local-client-cli/src/logger-formatter.ts b/frontend/local-client-cli/src/logger-formatter.ts index 9f237103..b98415b6 100644 --- a/frontend/local-client-cli/src/logger-formatter.ts +++ b/frontend/local-client-cli/src/logger-formatter.ts @@ -1,36 +1,21 @@ import { LogLevel, type LogLine } from "sync-client"; -// ANSI color codes -export const colors = { +const colors = { reset: "\x1b[0m", bold: "\x1b[1m", - dim: "\x1b[2m", - // Foreground colors red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", - blue: "\x1b[34m", magenta: "\x1b[35m", cyan: "\x1b[36m", gray: "\x1b[90m" } as const; -export function colorize(text: string, color: keyof typeof colors): string { +function colorize(text: string, color: keyof typeof colors): string { return `${colors[color]}${text}${colors.reset}`; } -/** - * Helper function to apply multiple color modifiers to text - */ -export function styleText( - text: string, - ...modifiers: (keyof typeof colors)[] -): string { - const prefix = modifiers.map((m) => colors[m]).join(""); - return `${prefix}${text}${colors.reset}`; -} - function formatTimestamp(date: Date): string { const [time] = date.toTimeString().split(" "); const ms = date.getMilliseconds().toString().padStart(3, "0"); diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index 3da8fc3a..7b736c22 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -6,6 +6,7 @@ import type { RelativePath, TextWithCursors } from "sync-client"; +import { toUnixPath } from "./path-utils"; export class NodeFileSystemOperations implements FileSystemOperations { public constructor(private readonly basePath: string) {} @@ -14,18 +15,12 @@ export class NodeFileSystemOperations implements FileSystemOperations { directory: RelativePath | undefined ): Promise { const files: RelativePath[] = []; - await this.walkDirectory( - directory !== undefined ? this.toNativePath(directory) : "", - files - ); + await this.walkDirectory(directory ?? "", files); return files; } public async read(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); + const fullPath = path.join(this.basePath, relativePath); try { return await fs.readFile(fullPath); } catch (error) { @@ -39,15 +34,12 @@ export class NodeFileSystemOperations implements FileSystemOperations { relativePath: RelativePath, content: Uint8Array ): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); + const fullPath = path.join(this.basePath, relativePath); const dir = path.dirname(fullPath); try { await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(fullPath, content); + await this.atomicWrite(fullPath, content); } catch (error) { throw new Error( `Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` @@ -59,15 +51,12 @@ export class NodeFileSystemOperations implements FileSystemOperations { relativePath: RelativePath, updater: (current: TextWithCursors) => TextWithCursors ): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); + const fullPath = path.join(this.basePath, relativePath); try { const currentContent = await fs.readFile(fullPath, "utf-8"); const result = updater({ text: currentContent, cursors: [] }); - await fs.writeFile(fullPath, result.text, "utf-8"); + await this.atomicWrite(fullPath, result.text, "utf-8"); return result.text; } catch (error) { throw new Error( @@ -77,10 +66,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { } public async getFileSize(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); + const fullPath = path.join(this.basePath, relativePath); try { const stats = await fs.stat(fullPath); return stats.size; @@ -92,10 +78,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { } public async exists(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); + const fullPath = path.join(this.basePath, relativePath); try { await fs.access(fullPath); return true; @@ -105,10 +88,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { } public async createDirectory(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); + const fullPath = path.join(this.basePath, relativePath); try { await fs.mkdir(fullPath, { recursive: false }); } catch (error) { @@ -119,10 +99,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { } public async delete(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); + const fullPath = path.join(this.basePath, relativePath); try { await fs.unlink(fullPath); } catch (error) { @@ -136,14 +113,8 @@ export class NodeFileSystemOperations implements FileSystemOperations { oldPath: RelativePath, newPath: RelativePath ): Promise { - const oldFullPath = path.join( - this.basePath, - this.toNativePath(oldPath) - ); - const newFullPath = path.join( - this.basePath, - this.toNativePath(newPath) - ); + const oldFullPath = path.join(this.basePath, oldPath); + const newFullPath = path.join(this.basePath, newPath); const newDir = path.dirname(newFullPath); try { @@ -156,6 +127,19 @@ export class NodeFileSystemOperations implements FileSystemOperations { } } + private async atomicWrite( + fullPath: string, + content: Uint8Array | string, + encoding?: BufferEncoding + ): Promise { + const tmpPath = fullPath + ".tmp"; + await fs.writeFile(tmpPath, content, encoding); + const fd = await fs.open(tmpPath, "r"); + await fd.datasync(); + await fd.close(); + await fs.rename(tmpPath, fullPath); + } + private async walkDirectory( relativePath: string, files: RelativePath[] @@ -179,28 +163,8 @@ export class NodeFileSystemOperations implements FileSystemOperations { await this.walkDirectory(entryRelativePath, files); } else if (entry.isFile()) { // Always return forward slashes - files.push(this.toUnixPath(entryRelativePath)); + files.push(toUnixPath(entryRelativePath)); } } } - - /** - * Convert a forward-slash path to native platform path separators - */ - private toNativePath(relativePath: string): string { - if (path.sep === "\\") { - return relativePath.replace(/\//g, "\\"); - } - return relativePath; - } - - /** - * Convert a native platform path to forward slashes - */ - private toUnixPath(nativePath: string): string { - if (path.sep === "\\") { - return nativePath.replace(/\\/g, "/"); - } - return nativePath; - } } diff --git a/frontend/local-client-cli/src/path-utils.test.ts b/frontend/local-client-cli/src/path-utils.test.ts new file mode 100644 index 00000000..13d33e6e --- /dev/null +++ b/frontend/local-client-cli/src/path-utils.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; +import { matchesGlob, toUnixPath } from "./path-utils"; + +test("matchesGlob - exact match", () => { + assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true); + assert.equal(matchesGlob("other", ".DS_Store"), false); +}); + +test("matchesGlob - dir/** matches directory and contents", () => { + assert.equal(matchesGlob(".git", ".git/**"), true); + assert.equal(matchesGlob(".git/config", ".git/**"), true); + assert.equal(matchesGlob(".git/refs/heads/main", ".git/**"), true); + assert.equal(matchesGlob(".gitignore", ".git/**"), false); +}); + +test("matchesGlob - * matches within a single segment", () => { + assert.equal(matchesGlob("foo.tmp", "*.tmp"), true); + assert.equal(matchesGlob("bar.tmp", "*.tmp"), true); + assert.equal(matchesGlob("foo.md", "*.tmp"), false); + assert.equal(matchesGlob("dir/foo.tmp", "*.tmp"), false); +}); + +test("matchesGlob - **/*.ext matches at any depth", () => { + assert.equal(matchesGlob("foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("dir/foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("a/b/c/foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("foo.md", "**/*.tmp"), false); +}); + +test("matchesGlob - ? matches single character", () => { + assert.equal(matchesGlob("a.md", "?.md"), true); + assert.equal(matchesGlob("ab.md", "?.md"), false); + assert.equal(matchesGlob(".md", "?.md"), false); +}); + +test("matchesGlob - dots are literal", () => { + assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true); + assert.equal(matchesGlob("xDS_Store", ".DS_Store"), false); +}); + +test("matchesGlob - node_modules/** matches directory tree", () => { + assert.equal(matchesGlob("node_modules", "node_modules/**"), true); + assert.equal(matchesGlob("node_modules/foo", "node_modules/**"), true); + assert.equal( + matchesGlob("node_modules/foo/bar/baz.js", "node_modules/**"), + true + ); + assert.equal(matchesGlob("not_node_modules", "node_modules/**"), false); +}); + +test("matchesGlob - **/ prefix matches zero or more segments", () => { + assert.equal(matchesGlob("test.log", "**/test.log"), true); + assert.equal(matchesGlob("dir/test.log", "**/test.log"), true); + assert.equal(matchesGlob("a/b/test.log", "**/test.log"), true); +}); + +test("toUnixPath - forward slashes unchanged", () => { + assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz"); +}); diff --git a/frontend/local-client-cli/src/path-utils.ts b/frontend/local-client-cli/src/path-utils.ts new file mode 100644 index 00000000..dd89fa67 --- /dev/null +++ b/frontend/local-client-cli/src/path-utils.ts @@ -0,0 +1,15 @@ +import * as path from "path"; + +// Convert a native platform path to forward slashes (no-op on non-Windows) +export function toUnixPath(nativePath: string): string { + return nativePath.split(path.sep).join(path.posix.sep); +} + +// Match a file path against a glob pattern +// Extends path.matchesGlob so that "dir/**" also matches the directory itself +export function matchesGlob(filePath: string, pattern: string): boolean { + if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) { + return true; + } + return path.matchesGlob(filePath, pattern); +} diff --git a/frontend/local-client-cli/tsconfig.json b/frontend/local-client-cli/tsconfig.json index 25f249c9..b07ec41a 100644 --- a/frontend/local-client-cli/tsconfig.json +++ b/frontend/local-client-cli/tsconfig.json @@ -18,7 +18,5 @@ "declarationMap": true, "sourceMap": true }, - "exclude": [ - "dist" - ] + "exclude": ["dist"] } diff --git a/frontend/local-client-cli/webpack.config.js b/frontend/local-client-cli/webpack.config.js index f8f48534..9226b9dc 100644 --- a/frontend/local-client-cli/webpack.config.js +++ b/frontend/local-client-cli/webpack.config.js @@ -2,32 +2,32 @@ const path = require("path"); const webpack = require("webpack"); module.exports = { - entry: { - cli: "./src/cli.ts", - healthcheck: "./src/healthcheck.ts" - }, - target: "node", - mode: "production", - optimization: { - minimize: false - }, - module: { - rules: [ - { - test: /\.ts$/, - use: "ts-loader" - } - ] - }, - resolve: { - extensions: [".ts", ".js"] - }, - output: { - globalObject: "this", - filename: "[name].js", - path: path.resolve(__dirname, "dist") - }, - plugins: [ - new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true }) + entry: { + cli: "./src/cli.ts", + healthcheck: "./src/healthcheck.ts" + }, + target: "node", + mode: "production", + optimization: { + minimize: false + }, + module: { + rules: [ + { + test: /\.ts$/, + use: "ts-loader" + } ] + }, + resolve: { + extensions: [".ts", ".js"] + }, + output: { + globalObject: "this", + filename: "[name].js", + path: path.resolve(__dirname, "dist") + }, + plugins: [ + new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true }) + ] }; diff --git a/frontend/obsidian-plugin/README.md b/frontend/obsidian-plugin/README.md index 93c2cba7..68e10a83 100644 --- a/frontend/obsidian-plugin/README.md +++ b/frontend/obsidian-plugin/README.md @@ -8,6 +8,7 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti **Note:** The Obsidian API is still in early alpha and is subject to change at any time! This sample plugin demonstrates some of the basic functionality the plugin API can do. + - Adds a ribbon icon, which shows a Notice when clicked. - Adds a command "Open Sample Modal" which opens a Modal. - Adds a plugin setting tab to the settings page. @@ -57,31 +58,6 @@ Quick starting guide for new plugin devs: - Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. - -## Funding URL - -You can include funding URLs where people who use your plugin can financially support it. - -The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: - -```json -{ - "fundingUrl": "https://buymeacoffee.com" -} -``` - -If you have multiple URLs, you can also do: - -```json -{ - "fundingUrl": { - "Buy Me a Coffee": "https://buymeacoffee.com", - "GitHub Sponsor": "https://github.com/sponsors", - "Patreon": "https://www.patreon.com/" - } -} -``` - ## API Documentation See https://github.com/obsidianmd/obsidian-api diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json index b7ae4909..d24e537b 100644 --- a/frontend/obsidian-plugin/package.json +++ b/frontend/obsidian-plugin/package.json @@ -13,25 +13,25 @@ "author": "", "license": "MIT", "devDependencies": { - "@types/node": "^24.8.1", + "@types/node": "^25.0.2", "css-loader": "^7.1.2", "date-fns": "^4.1.0", "file-loader": "^6.2.0", - "fs-extra": "^11.3.0", - "mini-css-extract-plugin": "^2.9.2", - "obsidian": "1.10.2", - "reconcile-text": "^0.8.0", + "fs-extra": "^11.3.2", + "mini-css-extract-plugin": "^2.9.4", + "obsidian": "1.11.0", + "reconcile-text": "^0.11.0", "resolve-url-loader": "^5.0.0", - "sass": "^1.91.0", + "sass": "^1.96.0", "sass-loader": "^16.0.6", "sync-client": "file:../sync-client", - "terser-webpack-plugin": "^5.3.14", - "ts-loader": "^9.5.2", + "terser-webpack-plugin": "^5.3.16", + "ts-loader": "^9.5.4", "tslib": "2.8.1", - "tsx": "^4.20.6", - "typescript": "5.8.3", + "tsx": "^4.21.0", + "typescript": "5.9.3", "url": "^0.11.4", - "webpack": "^5.99.9", + "webpack": "^5.103.0", "webpack-cli": "^6.0.1" } } diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 7d91b9f5..e222796b 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -135,14 +135,14 @@ export default class VaultLinkPlugin extends Plugin { nativeLineEndings: Platform.isWin ? "\r\n" : "\n", ...(IS_DEBUG_BUILD ? { - fetch: debugging.slowFetchFactory(1), - webSocket: debugging.slowWebSocketFactory(1, new Logger()) - } + fetch: debugging.slowFetchFactory(1), + webSocket: debugging.slowWebSocketFactory(1, new Logger()) + } : {}) }); if (IS_DEBUG_BUILD) { - debugging.logToConsole(client); + debugging.logToConsole(client.logger); } return client; @@ -231,9 +231,9 @@ export default class VaultLinkPlugin extends Plugin { } } ), - this.app.vault.on("create", async (file: TAbstractFile) => { + this.app.vault.on("create", (file: TAbstractFile) => { if (file instanceof TFile) { - await client.syncLocallyCreatedFile(file.path); + client.syncLocallyCreatedFile(file.path); } }), this.app.vault.on("modify", async (file: TAbstractFile) => { @@ -241,14 +241,14 @@ export default class VaultLinkPlugin extends Plugin { await this.rateLimitedUpdate(file.path, client); } }), - this.app.vault.on("delete", async (file: TAbstractFile) => { - await client.syncLocallyDeletedFile(file.path); + this.app.vault.on("delete", (file: TAbstractFile) => { + client.syncLocallyDeletedFile(file.path); }), this.app.vault.on( "rename", - async (file: TAbstractFile, oldPath: string) => { + (file: TAbstractFile, oldPath: string) => { if (file instanceof TFile) { - await client.syncLocallyUpdatedFile({ + client.syncLocallyUpdatedFile({ oldPath, relativePath: file.path }); @@ -267,13 +267,11 @@ export default class VaultLinkPlugin extends Plugin { if (!this.rateLimitedUpdatesPerFile.has(path)) { this.rateLimitedUpdatesPerFile.set( path, - rateLimit( - async () => - client.syncLocallyUpdatedFile({ - relativePath: path - }), - MIN_WAIT_BETWEEN_UPDATES_IN_MS - ) + rateLimit(async () => { + client.syncLocallyUpdatedFile({ + relativePath: path + }); + }, MIN_WAIT_BETWEEN_UPDATES_IN_MS) ); } await this.rateLimitedUpdatesPerFile.get(path)?.(); diff --git a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts index 3088c640..409402a4 100644 --- a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts +++ b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts @@ -14,7 +14,9 @@ export function renderCursorsInFileExplorer( app: App ): void { const fileExplorers = app.workspace.getLeavesOfType("file-explorer"); - if (fileExplorers.length == 0) return; + if (fileExplorers.length == 0) { + return; + } const [fileExplorer] = fileExplorers; @@ -34,7 +36,7 @@ export function renderCursorsInFileExplorer( (parent) => { cursors.forEach((cursor) => { cursor.documentsWithCursors.forEach((document) => { - if (document.relative_path.startsWith(key)) { + if (document.relativePath.startsWith(key)) { parent.appendChild( createSpan({ text: cursor.userName, diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts index 1191d9a2..4200a72a 100644 --- a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -61,7 +61,7 @@ export class RemoteCursorsPluginValue implements PluginValue { return clientCursors.flatMap((cursor) => cursor.cursors.map((span) => ({ name: client.userName, - path: cursor.relative_path, + path: cursor.relativePath, deviceId: client.deviceId, isOutdated: client.isOutdated, span: { ...span } @@ -132,7 +132,8 @@ export class RemoteCursorsPluginValue implements PluginValue { ] ) }, - edited + edited, + "Markdown" ); reconciled.cursors.forEach(({ id, position }) => { diff --git a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts index 213c0d2c..5a5823c2 100644 --- a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts +++ b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts @@ -266,9 +266,8 @@ export class SyncSettingsTab extends PluginSettingTab { new Notice("Checking connection to the server..."); new Notice( - ( - await this.syncClient.checkConnection() - ).serverMessage + (await this.syncClient.checkConnection()) + .serverMessage ); await this.statusDescription.updateConnectionState(); } else { @@ -351,22 +350,6 @@ export class SyncSettingsTab extends PluginSettingTab { }) ); - new Setting(containerEl) - .setName("Sync concurrency") - .setDesc( - "How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1." - ) - .addSlider((text) => - text - .setLimits(1, 16, 1) - .setDynamicTooltip() - .setInstant(false) - .setValue(this.syncClient.getSettings().syncConcurrency) - .onChange(async (value) => - this.syncClient.setSetting("syncConcurrency", value) - ) - ); - new Setting(containerEl) .setName("Maximum file size to be uploaded (MB)") .setDesc( @@ -484,40 +467,6 @@ export class SyncSettingsTab extends PluginSettingTab { ); }) ); - - new Setting(containerEl) - .setName("Minimum save interval (ms)") - .setDesc( - "The minimum time between saving settings and database to disk, in milliseconds. Lower values save more frequently but may impact performance." - ) - .addText((input) => - input - .setValue( - this.syncClient - .getSettings() - .minimumSaveIntervalMs.toString() - ) - .onChange(async (value) => { - if (value === "") { - return; - } - let parsedValue = Number.parseInt(value, 10); - if (Number.isNaN(parsedValue) || parsedValue < 0) { - parsedValue = - this.syncClient.getSettings() - .minimumSaveIntervalMs; - } - - if (value !== parsedValue.toString()) { - input.setValue(parsedValue.toString()); - } - - return this.syncClient.setSetting( - "minimumSaveIntervalMs", - parsedValue - ); - }) - ); } private setStatusDescriptionSubscription( diff --git a/frontend/obsidian-plugin/src/views/status-description/status-description.ts b/frontend/obsidian-plugin/src/views/status-description/status-description.ts index 53fea486..6d8d74fe 100644 --- a/frontend/obsidian-plugin/src/views/status-description/status-description.ts +++ b/frontend/obsidian-plugin/src/views/status-description/status-description.ts @@ -88,7 +88,7 @@ export class StatusDescription { text: ` and has indexed approximately ` }); container.createSpan({ - text: `${this.syncClient.documentCount}`, + text: `${this.syncClient.syncedDocumentCount}`, cls: "number" }); container.createSpan({ diff --git a/frontend/obsidian-plugin/tsconfig.json b/frontend/obsidian-plugin/tsconfig.json index 81af03a7..7ec2a9cd 100644 --- a/frontend/obsidian-plugin/tsconfig.json +++ b/frontend/obsidian-plugin/tsconfig.json @@ -6,12 +6,7 @@ "strict": true, "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, - "lib": [ - "DOM", - "ES2024" - ] + "lib": ["DOM", "ES2024"] }, - "exclude": [ - "./dist" - ] + "exclude": ["./dist"] } diff --git a/frontend/obsidian-plugin/webpack.config.js b/frontend/obsidian-plugin/webpack.config.js index b749b20d..794f30de 100644 --- a/frontend/obsidian-plugin/webpack.config.js +++ b/frontend/obsidian-plugin/webpack.config.js @@ -46,7 +46,7 @@ module.exports = (env, argv) => ({ const source = path.resolve(__dirname, "dist"); const destinations = [ "/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link", - "/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link", + "/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link" // "/home/andras/obsidian-test/.obsidian/plugins/vault-link" ]; destinations.forEach((destination) => { From 201f9aeaee5b90fbdc97f8d900c98c9ff44bd6d2 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 13:46:48 +0100 Subject: [PATCH 17/19] Remove clutter --- frontend/local-client-cli/src/args.test.ts | 237 --------------------- 1 file changed, 237 deletions(-) diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts index c075d193..fdf0b6c8 100644 --- a/frontend/local-client-cli/src/args.test.ts +++ b/frontend/local-client-cli/src/args.test.ts @@ -150,25 +150,6 @@ test("parseArgs - default log level is INFO", () => { assert.equal(args.logLevel, LogLevel.INFO); }); -test("parseArgs - parse DEBUG log level", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "DEBUG" - ]); - - assert.equal(args.logLevel, LogLevel.DEBUG); -}); - test("parseArgs - parse ERROR log level", () => { const args = parseArgs([ "node", @@ -188,43 +169,6 @@ test("parseArgs - parse ERROR log level", () => { assert.equal(args.logLevel, LogLevel.ERROR); }); -test("parseArgs - log level is case insensitive", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "debug" - ]); - - assert.equal(args.logLevel, LogLevel.DEBUG); -}); - -test("parseArgs - throws on invalid log level", () => { - assert.throws(() => { - parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "INVALID" - ]); - }, /Invalid log level/); -}); test("parseArgs - reads required options from environment variables", () => { process.env.VAULTLINK_LOCAL_PATH = "/env/path"; @@ -267,184 +211,3 @@ test("parseArgs - CLI arguments take precedence over environment variables", () delete process.env.VAULTLINK_TOKEN; } }); - -test("parseArgs - reads log level from environment variable", () => { - process.env.VAULTLINK_LOG_LEVEL = "DEBUG"; - - try { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - assert.equal(args.logLevel, LogLevel.DEBUG); - } finally { - delete process.env.VAULTLINK_LOG_LEVEL; - } -}); - -test("parseArgs - quiet defaults to false", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - - assert.equal(args.quiet, false); -}); - -test("parseArgs - parse --quiet flag", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--quiet" - ]); - - assert.equal(args.quiet, true); -}); - -test("parseArgs - parse -q short flag", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "-q" - ]); - - assert.equal(args.quiet, true); -}); - -test("parseArgs - line-endings defaults to auto", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - - assert.equal(args.lineEndings, "auto"); -}); - -test("parseArgs - parse --line-endings lf", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--line-endings", - "lf" - ]); - - assert.equal(args.lineEndings, "lf"); -}); - -test("parseArgs - parse --line-endings crlf", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--line-endings", - "crlf" - ]); - - assert.equal(args.lineEndings, "crlf"); -}); - -test("parseArgs - throws on invalid remote URI protocol", () => { - assert.throws(() => { - parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "ftp://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - }, /Invalid remote URI/); -}); - -test("parseArgs - accepts http:// remote URI", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "http://localhost:3000", - "-t", - "mytoken", - "-v", - "default" - ]); - - assert.equal(args.remoteUri, "http://localhost:3000"); -}); - -test("parseArgs - accepts wss:// remote URI", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "wss://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - - assert.equal(args.remoteUri, "wss://sync.example.com"); -}); From 6647a4e63234435e57ebc779befb0c258c99802e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 14:17:52 +0100 Subject: [PATCH 18/19] Improvements --- frontend/local-client-cli/src/args.ts | 80 +++++++++++-------- frontend/local-client-cli/src/cli.ts | 45 ++++++----- .../local-client-cli/src/node-filesystem.ts | 28 +++++-- frontend/local-client-cli/src/path-utils.ts | 10 ++- frontend/obsidian-plugin/webpack.config.js | 1 - 5 files changed, 104 insertions(+), 60 deletions(-) diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 442c4817..34d839b1 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -2,7 +2,8 @@ import { Command, Option } from "commander"; import packageJson from "../package.json"; import { LogLevel } from "sync-client"; -type LineEndingMode = "auto" | "lf" | "crlf"; +export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const; +export type LineEndingMode = (typeof LINE_ENDING_MODES)[number]; interface CliArgs { remoteUri: string; @@ -21,6 +22,35 @@ interface CliArgs { const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"]; +const REQUIRED_OPTIONS = { + localPath: { + flags: "-l, --local-path ", + env: "VAULTLINK_LOCAL_PATH" + }, + remoteUri: { + flags: "-r, --remote-uri ", + env: "VAULTLINK_REMOTE_URI" + }, + token: { flags: "-t, --token ", env: "VAULTLINK_TOKEN" }, + vaultName: { + flags: "-v, --vault-name ", + env: "VAULTLINK_VAULT_NAME" + } +} as const; + +function requireOption( + value: T | undefined, + name: keyof typeof REQUIRED_OPTIONS +): T { + if (value === undefined) { + const { flags, env } = REQUIRED_OPTIONS[name]; + throw new Error( + `required option '${flags}' not specified (or set ${env})` + ); + } + return value; +} + export function parseArgs(argv: string[]): CliArgs { const program = new Command(); @@ -32,23 +62,25 @@ export function parseArgs(argv: string[]): CliArgs { .version(packageJson.version) .addOption( new Option( - "-l, --local-path ", + REQUIRED_OPTIONS.localPath.flags, "Local directory path to sync" - ).env("VAULTLINK_LOCAL_PATH") + ).env(REQUIRED_OPTIONS.localPath.env) ) .addOption( - new Option("-r, --remote-uri ", "Remote server URI").env( - "VAULTLINK_REMOTE_URI" - ) + new Option( + REQUIRED_OPTIONS.remoteUri.flags, + "Remote server URI" + ).env(REQUIRED_OPTIONS.remoteUri.env) ) .addOption( - new Option("-t, --token ", "Authentication token").env( - "VAULTLINK_TOKEN" - ) + new Option( + REQUIRED_OPTIONS.token.flags, + "Authentication token" + ).env(REQUIRED_OPTIONS.token.env) ) .addOption( - new Option("-v, --vault-name ", "Vault name").env( - "VAULTLINK_VAULT_NAME" + new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env( + REQUIRED_OPTIONS.vaultName.env ) ) .addOption( @@ -105,7 +137,7 @@ export function parseArgs(argv: string[]): CliArgs { "[OPTIONAL] Line ending style: auto (platform default), lf, crlf" ) .default("auto") - .choices(["auto", "lf", "crlf"]) + .choices([...LINE_ENDING_MODES]) .env("VAULTLINK_LINE_ENDINGS") ) .addHelpText( @@ -144,22 +176,6 @@ Environment variables: const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto"; /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ - const requireOption = (value: T | undefined, name: string): T => { - if (value === undefined) { - const option = program.options.find( - (o) => o.attributeName() === name - ); - const envHint = - option?.envVar !== undefined - ? ` (or set ${option.envVar})` - : ""; - throw new Error( - `required option '${option?.flags ?? name}' not specified${envHint}` - ); - } - return value; - }; - const requiredLocalPath = requireOption(localPath, "localPath"); const requiredRemoteUri = requireOption(remoteUri, "remoteUri"); const requiredToken = requireOption(token, "token"); @@ -187,13 +203,11 @@ Environment variables: } const logLevel = logLevelUpper; - const validLineEndings: readonly string[] = ["auto", "lf", "crlf"]; - const isLineEndingMode = (value: string): value is LineEndingMode => { - return validLineEndings.includes(value); - }; + const isLineEndingMode = (value: string): value is LineEndingMode => + (LINE_ENDING_MODES as readonly string[]).includes(value); if (!isLineEndingMode(lineEndingsStr)) { throw new Error( - `Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${validLineEndings.join(", ")}` + `Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}` ); } const lineEndings = lineEndingsStr; diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index e06fda47..31c81d5c 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -7,12 +7,12 @@ import { DEFAULT_SETTINGS, Logger, LogLevel, - type LogLine, + LogLine, type SyncSettings, type StoredDatabase } from "sync-client"; -import { parseArgs } from "./args"; -import { NodeFileSystemOperations } from "./node-filesystem"; +import { parseArgs, type LineEndingMode } from "./args"; +import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem"; import { FileWatcher } from "./file-watcher"; import { formatLogLine } from "./logger-formatter"; import packageJson from "../package.json"; @@ -50,7 +50,7 @@ function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void { const HEALTH_CHECK_INTERVAL_MS = 30 * 1000; const PROGRESS_LOG_INTERVAL_MS = 2000; -function resolveLineEndings(mode: "auto" | "lf" | "crlf"): string { +function resolveLineEndings(mode: LineEndingMode): string { switch (mode) { case "lf": return "\n"; @@ -65,9 +65,13 @@ async function main(): Promise { const args = parseArgs(process.argv); const absolutePath = path.resolve(args.localPath); - const logger = new Logger(); const logHandler = createLogHandler(args.logLevel); - logger.onLogEmitted.add(logHandler); + // Boot-time messages are emitted directly through logHandler before the + // SyncClient (and its Logger) exist; afterwards every log line flows + // through client.logger. + const emitBoot = (level: LogLevel, message: string): void => { + logHandler(new LogLine(level, message)); + }; if (!fsSync.existsSync(absolutePath)) { fsSync.mkdirSync(absolutePath, { recursive: true }); @@ -76,27 +80,31 @@ async function main(): Promise { try { const stats = await fs.stat(absolutePath); if (!stats.isDirectory()) { - logger.error(`${absolutePath} is not a directory`); + emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`); process.exit(1); } } catch (error) { - logger.error( + emitBoot( + LogLevel.ERROR, `Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); } if (!args.quiet) { - logger.info(`VaultLink Local CLI v${packageJson.version}`); - logger.info(`Local path: ${absolutePath}`); - logger.info(`Remote URI: ${args.remoteUri}`); - logger.info(`Vault name: ${args.vaultName}`); + emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`); + emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`); + emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`); + emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`); if (args.lineEndings !== "auto") { - logger.info(`Line endings: ${args.lineEndings.toUpperCase()}`); + emitBoot( + LogLevel.INFO, + `Line endings: ${args.lineEndings.toUpperCase()}` + ); } } - const dataDir = path.join(absolutePath, ".vaultlink"); + const dataDir = path.join(absolutePath, VAULTLINK_DIR); const dataFile = path.join(dataDir, "sync-data.json"); await fs.mkdir(dataDir, { recursive: true }); @@ -105,8 +113,7 @@ async function main(): Promise { const ignorePatterns = [ ...(args.ignorePatterns ?? []), - ".vaultlink/**", - ".git/**" + `${VAULTLINK_DIR}/**` ]; const settings: SyncSettings = { @@ -134,7 +141,10 @@ async function main(): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion database = JSON.parse(content) as Partial; } catch { - logger.warn(`Cannot read data file at ${dataFile}`); + emitBoot( + LogLevel.WARNING, + `Cannot read data file at ${dataFile}` + ); } return { @@ -269,7 +279,6 @@ async function main(): Promise { } main().catch((error: unknown) => { - // Last-resort handler before the logger exists // eslint-disable-next-line no-console console.error( `Unexpected error: ${error instanceof Error ? error.message : String(error)}` diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index 7b736c22..08db361e 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -1,6 +1,7 @@ import * as fs from "fs/promises"; import type { Dirent } from "fs"; import * as path from "path"; +import { randomUUID } from "crypto"; import type { FileSystemOperations, RelativePath, @@ -8,6 +9,11 @@ import type { } from "sync-client"; import { toUnixPath } from "./path-utils"; +// VaultLink's per-vault metadata directory. Holds the persisted sync database +// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**` +// ignore pattern keeps everything in here invisible to the file watcher. +export const VAULTLINK_DIR = ".vaultlink"; + export class NodeFileSystemOperations implements FileSystemOperations { public constructor(private readonly basePath: string) {} @@ -132,12 +138,22 @@ export class NodeFileSystemOperations implements FileSystemOperations { content: Uint8Array | string, encoding?: BufferEncoding ): Promise { - const tmpPath = fullPath + ".tmp"; - await fs.writeFile(tmpPath, content, encoding); - const fd = await fs.open(tmpPath, "r"); - await fd.datasync(); - await fd.close(); - await fs.rename(tmpPath, fullPath); + const tmpDir = path.join(this.basePath, VAULTLINK_DIR); + await fs.mkdir(tmpDir, { recursive: true }); + const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`); + try { + await fs.writeFile(tmpPath, content, encoding); + const fd = await fs.open(tmpPath, "r"); + try { + await fd.datasync(); + } finally { + await fd.close(); + } + await fs.rename(tmpPath, fullPath); + } catch (error) { + await fs.unlink(tmpPath).catch(() => undefined); + throw error; + } } private async walkDirectory( diff --git a/frontend/local-client-cli/src/path-utils.ts b/frontend/local-client-cli/src/path-utils.ts index dd89fa67..1ead144c 100644 --- a/frontend/local-client-cli/src/path-utils.ts +++ b/frontend/local-client-cli/src/path-utils.ts @@ -5,8 +5,14 @@ export function toUnixPath(nativePath: string): string { return nativePath.split(path.sep).join(path.posix.sep); } -// Match a file path against a glob pattern -// Extends path.matchesGlob so that "dir/**" also matches the directory itself +// Match a file path against a glob pattern. +// +// Behaves like Node's path.matchesGlob with one extension: `dir/**` matches +// the directory `dir` itself, not only its descendants. The watcher feeds us +// a directory's relative path (e.g. ".git") at the same time it's about to +// recurse into it, and the natural way for users to write the ignore pattern +// is `.git/**` — under stdlib semantics that pattern would let the directory +// through and only block its children, defeating the prune. export function matchesGlob(filePath: string, pattern: string): boolean { if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) { return true; diff --git a/frontend/obsidian-plugin/webpack.config.js b/frontend/obsidian-plugin/webpack.config.js index 794f30de..12844fd7 100644 --- a/frontend/obsidian-plugin/webpack.config.js +++ b/frontend/obsidian-plugin/webpack.config.js @@ -47,7 +47,6 @@ module.exports = (env, argv) => ({ const destinations = [ "/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link", "/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link" - // "/home/andras/obsidian-test/.obsidian/plugins/vault-link" ]; destinations.forEach((destination) => { fs.copy(source, destination) From d99e249fa53a771b0665b9a8cb84ee89d775f412 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 14:20:36 +0100 Subject: [PATCH 19/19] Durable rename --- .../local-client-cli/src/node-filesystem.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index 08db361e..ba95ab6a 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -15,7 +15,7 @@ import { toUnixPath } from "./path-utils"; export const VAULTLINK_DIR = ".vaultlink"; export class NodeFileSystemOperations implements FileSystemOperations { - public constructor(private readonly basePath: string) {} + public constructor(private readonly basePath: string) { } public async listFilesRecursively( directory: RelativePath | undefined @@ -150,12 +150,27 @@ export class NodeFileSystemOperations implements FileSystemOperations { await fd.close(); } await fs.rename(tmpPath, fullPath); + await this.syncDirectory(path.dirname(fullPath)); } catch (error) { await fs.unlink(tmpPath).catch(() => undefined); throw error; } } + // Make the rename durable by fsync'ing the destination's parent directory. + // Skipped on Windows: fsync on a directory handle isn't supported there + private async syncDirectory(dir: string): Promise { + if (process.platform === "win32") { + return; + } + const fd = await fs.open(dir, "r"); + try { + await fd.sync(); + } finally { + await fd.close(); + } + } + private async walkDirectory( relativePath: string, files: RelativePath[]