mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 10:33:21 +00:00
Adopt Commonlib 0.2.0 with injected device storage
This commit is contained in:
1 parent
e56098eb57
commit
96ad2737a7
60 files changed
+1499
-541
No files matched your search
@@ -309,6 +309,8 @@ Existing legacy Modules continue to register their handlers in `onBindFunction()
|
|||||||
database and Replicator readiness. See
|
database and Replicator readiness. See
|
||||||
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
|
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
|
||||||
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
|
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
|
||||||
|
- Inject `@vrtmrz/value-store` at host composition. `setting.deviceValues` uses exact device-local keys; `setting.config` and the Service Hub `config` share the existing Vault and database-suffix scope. Await reads, writes, and deletions before advancing the workflow. Browser and Obsidian hosts retain their Web Storage keys, and the CLI retains its existing flat JSON string map.
|
||||||
|
- Seed the settings tab with `await settingTab.loadSettings()` before registration. Its editing buffer remains synchronous for Obsidian rendering; storage failures must not replace a previously loaded value with an empty default.
|
||||||
|
|
||||||
### Database Operations
|
### Database Operations
|
||||||
|
|
||||||
|
|||||||
Generated
+14
-4
@@ -23,9 +23,10 @@
|
|||||||
"@smithy/types": "^4.14.3",
|
"@smithy/types": "^4.14.3",
|
||||||
"@smithy/util-retry": "^4.4.5",
|
"@smithy/util-retry": "^4.4.5",
|
||||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||||
"@vrtmrz/livesync-commonlib": "0.1.44",
|
"@vrtmrz/livesync-commonlib": "0.2.0",
|
||||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||||
"@vrtmrz/ui-interactions": "0.1.2",
|
"@vrtmrz/ui-interactions": "0.1.2",
|
||||||
|
"@vrtmrz/value-store": "0.1.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"markdown-it": "^14.3.2",
|
"markdown-it": "^14.3.2",
|
||||||
@@ -4115,9 +4116,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||||
"version": "0.1.44",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.44.tgz",
|
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.2.0.tgz",
|
||||||
"integrity": "sha512-GjXNSLTnWLZzdgrhW3sIOsLqGLP4iMgypPMtcBc2Ju2vMq64TRA1Llfoya1KRIeu6m0G7Xu3ZtkWARTRcHkYYg==",
|
"integrity": "sha512-K24Pkke+FTjZI7DjIV7qkZk+BmC9VebN3da+2cVD9SWOgoGl7OvDyXTHDafbrmmM4vi4ydaydAEh9glO1xHF6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-crypto/sha256-js": "5.2.0",
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
@@ -4128,6 +4129,7 @@
|
|||||||
"@smithy/types": "^4.14.3",
|
"@smithy/types": "^4.14.3",
|
||||||
"@smithy/util-retry": "^4.4.5",
|
"@smithy/util-retry": "^4.4.5",
|
||||||
"@trystero-p2p/nostr": "0.25.3",
|
"@trystero-p2p/nostr": "0.25.3",
|
||||||
|
"@vrtmrz/value-store": "0.1.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
@@ -4139,9 +4141,11 @@
|
|||||||
"pouchdb-adapter-idb": "^9.0.0",
|
"pouchdb-adapter-idb": "^9.0.0",
|
||||||
"pouchdb-adapter-indexeddb": "^9.0.0",
|
"pouchdb-adapter-indexeddb": "^9.0.0",
|
||||||
"pouchdb-adapter-memory": "^9.0.0",
|
"pouchdb-adapter-memory": "^9.0.0",
|
||||||
|
"pouchdb-checkpointer": "9.0.0",
|
||||||
"pouchdb-core": "^9.0.0",
|
"pouchdb-core": "^9.0.0",
|
||||||
"pouchdb-errors": "^9.0.0",
|
"pouchdb-errors": "^9.0.0",
|
||||||
"pouchdb-find": "^9.0.0",
|
"pouchdb-find": "^9.0.0",
|
||||||
|
"pouchdb-generate-replication-id": "9.0.0",
|
||||||
"pouchdb-mapreduce": "^9.0.0",
|
"pouchdb-mapreduce": "^9.0.0",
|
||||||
"pouchdb-merge": "^9.0.0",
|
"pouchdb-merge": "^9.0.0",
|
||||||
"pouchdb-replication": "^9.0.0",
|
"pouchdb-replication": "^9.0.0",
|
||||||
@@ -4210,6 +4214,12 @@
|
|||||||
"integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==",
|
"integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@vrtmrz/value-store": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vrtmrz/value-store/-/value-store-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-M62f1j0bnJkqpgI5sYxnkXUAOSDEwQbYrLf+w58gCCm4sjIXDmFzDJd2duXabRbktBlACt9P99yjN/9gOAPxrA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/abstract-leveldown": {
|
"node_modules/abstract-leveldown": {
|
||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
|
||||||
|
|||||||
+2
-1
@@ -193,9 +193,10 @@
|
|||||||
"@smithy/types": "^4.14.3",
|
"@smithy/types": "^4.14.3",
|
||||||
"@smithy/util-retry": "^4.4.5",
|
"@smithy/util-retry": "^4.4.5",
|
||||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||||
"@vrtmrz/livesync-commonlib": "0.1.44",
|
"@vrtmrz/livesync-commonlib": "0.2.0",
|
||||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||||
"@vrtmrz/ui-interactions": "0.1.2",
|
"@vrtmrz/ui-interactions": "0.1.2",
|
||||||
|
"@vrtmrz/value-store": "0.1.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"markdown-it": "^14.3.2",
|
"markdown-it": "^14.3.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||||
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||||
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||||
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
|
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
|
||||||
@@ -11,7 +11,7 @@ import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
|||||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||||
API: InjectableAPIService<T>;
|
API: InjectableAPIService<T>;
|
||||||
appLifecycle: AppLifecycleService<T>;
|
appLifecycle: AppLifecycleService<T>;
|
||||||
config: ConfigService<T>;
|
config: IConfigService;
|
||||||
control: ControlService<T>;
|
control: ControlService<T>;
|
||||||
replicator: ReplicatorService<T>;
|
replicator: ReplicatorService<T>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||||
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
||||||
import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
|
||||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||||
import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
|
||||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
|
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
|
||||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||||
@@ -24,10 +23,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
|||||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||||
import { BrowserConfirm } from "./BrowserConfirm";
|
import { BrowserConfirm } from "./BrowserConfirm";
|
||||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||||
import {
|
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
||||||
LiveSyncBrowserAPIService,
|
|
||||||
type LiveSyncBrowserAPIServiceOptions,
|
|
||||||
} from "./LiveSyncBrowserAPIService";
|
|
||||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||||
|
|
||||||
export interface LiveSyncBrowserSettingsPersistence {
|
export interface LiveSyncBrowserSettingsPersistence {
|
||||||
@@ -43,6 +39,7 @@ export interface LiveSyncBrowserRestartPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||||
|
deviceValues: ValueStore & ValueDeleter;
|
||||||
context?: T;
|
context?: T;
|
||||||
getSystemVaultName?: () => string;
|
getSystemVaultName?: () => string;
|
||||||
settings?: LiveSyncBrowserSettingsPersistence;
|
settings?: LiveSyncBrowserSettingsPersistence;
|
||||||
@@ -51,35 +48,12 @@ export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
|||||||
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
|
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LiveSyncBrowserAppLifecycleService<
|
class LiveSyncBrowserAppLifecycleService<T extends ServiceContext> extends InjectableAppLifecycleService<T> {}
|
||||||
T extends ServiceContext,
|
|
||||||
> extends InjectableAppLifecycleService<T> {}
|
|
||||||
|
|
||||||
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
|
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
|
||||||
|
|
||||||
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
|
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
|
||||||
|
|
||||||
class LiveSyncBrowserConfigService<T extends ServiceContext> extends ConfigService<T> {
|
|
||||||
constructor(
|
|
||||||
context: T,
|
|
||||||
private readonly setting: ISettingService
|
|
||||||
) {
|
|
||||||
super(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSmallConfig(key: string): string | null {
|
|
||||||
return this.setting.getSmallConfig(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSmallConfig(key: string, value: string): void {
|
|
||||||
this.setting.setSmallConfig(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteSmallConfig(key: string): void {
|
|
||||||
this.setting.deleteSmallConfig(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||||
@@ -98,17 +72,14 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
|||||||
const setting = new InjectableSettingService(context, {
|
const setting = new InjectableSettingService(context, {
|
||||||
APIService: API,
|
APIService: API,
|
||||||
onDisplayLanguageChanged: setLang,
|
onDisplayLanguageChanged: setLang,
|
||||||
|
deviceValues: options.deviceValues,
|
||||||
});
|
});
|
||||||
const settingsPersistence = options.settings;
|
const settingsPersistence = options.settings;
|
||||||
setting.loadData.setHandler(
|
setting.loadData.setHandler(
|
||||||
settingsPersistence
|
settingsPersistence ? () => settingsPersistence.load() : () => Promise.resolve(undefined)
|
||||||
? () => settingsPersistence.load()
|
|
||||||
: () => Promise.resolve(undefined)
|
|
||||||
);
|
);
|
||||||
setting.saveData.setHandler(
|
setting.saveData.setHandler(
|
||||||
settingsPersistence
|
settingsPersistence ? (settings) => settingsPersistence.save(settings) : () => Promise.resolve()
|
||||||
? (settings) => settingsPersistence.save(settings)
|
|
||||||
: () => Promise.resolve()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
|
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
|
||||||
@@ -142,7 +113,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
|||||||
setting,
|
setting,
|
||||||
API,
|
API,
|
||||||
});
|
});
|
||||||
const config = new LiveSyncBrowserConfigService(context, setting);
|
const config = setting.config;
|
||||||
const replicator = new InjectableReplicatorService(context, {
|
const replicator = new InjectableReplicatorService(context, {
|
||||||
settingService: setting,
|
settingService: setting,
|
||||||
appLifecycleService: appLifecycle,
|
appLifecycleService: appLifecycle,
|
||||||
@@ -163,8 +134,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
|||||||
databaseService: database,
|
databaseService: database,
|
||||||
});
|
});
|
||||||
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
|
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
|
||||||
openKeyValueDatabase:
|
openKeyValueDatabase: options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
|
||||||
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
|
|
||||||
appLifecycle,
|
appLifecycle,
|
||||||
databaseEvents,
|
databaseEvents,
|
||||||
vault,
|
vault,
|
||||||
@@ -209,7 +179,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
|
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
|
||||||
options: LiveSyncBrowserServiceHubOptions<T> = {}
|
options: LiveSyncBrowserServiceHubOptions<T>
|
||||||
): LiveSyncBrowserServiceHub<T> {
|
): LiveSyncBrowserServiceHub<T> {
|
||||||
return new LiveSyncBrowserServiceHub(options);
|
return new LiveSyncBrowserServiceHub(options);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +22,8 @@ describe("LiveSync browser service context contract", () => {
|
|||||||
const context = createServiceContext({
|
const context = createServiceContext({
|
||||||
translate: (key) => `webapp:${key}`,
|
translate: (key) => `webapp:${key}`,
|
||||||
});
|
});
|
||||||
const hub = createLiveSyncBrowserServiceHub({ context });
|
const deviceValues = new MemoryValueStore();
|
||||||
|
const hub = createLiveSyncBrowserServiceHub({ context, deviceValues });
|
||||||
|
|
||||||
expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({
|
expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({
|
||||||
translation: "webapp:moduleLocalDatabase.logWaitingForReady",
|
translation: "webapp:moduleLocalDatabase.logWaitingForReady",
|
||||||
@@ -32,20 +34,17 @@ describe("LiveSync browser service context contract", () => {
|
|||||||
expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual(
|
expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual(
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
expect(hub.setting.deviceValues).toBe(deviceValues);
|
||||||
|
expect(hub.config).toBe(hub.setting.config);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
|
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
|
||||||
const deviceLocalSettings = new Map<string, string>();
|
const deviceValues = new MemoryValueStore();
|
||||||
vi.stubGlobal("localStorage", {
|
|
||||||
getItem: (key: string) => deviceLocalSettings.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => deviceLocalSettings.set(key, value),
|
|
||||||
removeItem: (key: string) => deviceLocalSettings.delete(key),
|
|
||||||
clear: () => deviceLocalSettings.clear(),
|
|
||||||
});
|
|
||||||
const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
|
const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
|
||||||
const restartCalls: string[] = [];
|
const restartCalls: string[] = [];
|
||||||
const nativeFetch = vi.fn(async () => new Response("ok"));
|
const nativeFetch = vi.fn(async () => new Response("ok"));
|
||||||
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
|
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
|
||||||
|
deviceValues,
|
||||||
getSystemVaultName: () => "browser-vault",
|
getSystemVaultName: () => "browser-vault",
|
||||||
settings: {
|
settings: {
|
||||||
load: async () => ({
|
load: async () => ({
|
||||||
@@ -69,6 +68,8 @@ describe("LiveSync browser service context contract", () => {
|
|||||||
|
|
||||||
const hub = createLiveSyncBrowserServiceHub(options);
|
const hub = createLiveSyncBrowserServiceHub(options);
|
||||||
|
|
||||||
|
expect(hub.setting.deviceValues).toBe(deviceValues);
|
||||||
|
expect(hub.config).toBe(hub.setting.config);
|
||||||
expect(hub.API.getSystemVaultName()).toBe("browser-vault");
|
expect(hub.API.getSystemVaultName()).toBe("browser-vault");
|
||||||
expect(hub.API.getPlatform()).toBe("browser");
|
expect(hub.API.getPlatform()).toBe("browser");
|
||||||
const response = await hub.API.nativeFetch("https://example.invalid/");
|
const response = await hub.API.nativeFetch("https://example.invalid/");
|
||||||
@@ -86,4 +87,30 @@ describe("LiveSync browser service context contract", () => {
|
|||||||
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
|
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
|
||||||
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
|
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps each browser composition bound to its own device value store", async () => {
|
||||||
|
const firstValues = new MemoryValueStore();
|
||||||
|
const secondValues = new MemoryValueStore();
|
||||||
|
const firstHub = createLiveSyncBrowserServiceHub({
|
||||||
|
deviceValues: firstValues,
|
||||||
|
getSystemVaultName: () => "first-vault",
|
||||||
|
});
|
||||||
|
const secondHub = createLiveSyncBrowserServiceHub({
|
||||||
|
deviceValues: secondValues,
|
||||||
|
getSystemVaultName: () => "second-vault",
|
||||||
|
});
|
||||||
|
|
||||||
|
await firstHub.config.setValue("probe", "first");
|
||||||
|
await secondHub.config.setValue("probe", "second");
|
||||||
|
|
||||||
|
expect(firstHub.setting.deviceValues).toBe(firstValues);
|
||||||
|
expect(secondHub.setting.deviceValues).toBe(secondValues);
|
||||||
|
expect(firstHub.config).toBe(firstHub.setting.config);
|
||||||
|
expect(secondHub.config).toBe(secondHub.setting.config);
|
||||||
|
expect(firstHub.config).not.toBe(secondHub.config);
|
||||||
|
await expect(firstHub.config.getValue("probe")).resolves.toBe("first");
|
||||||
|
await expect(secondHub.config.getValue("probe")).resolves.toBe("second");
|
||||||
|
await expect(firstValues.getValue("first-vault-probe")).resolves.toBe("first");
|
||||||
|
await expect(secondValues.getValue("second-vault-probe")).resolves.toBe("second");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -719,8 +719,8 @@ src/apps/cli/
|
|||||||
│ └── ServiceFileAccessImpl.ts
|
│ └── ServiceFileAccessImpl.ts
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── NodeKeyValueDBService.ts
|
│ ├── NodeKeyValueDBService.ts
|
||||||
│ ├── NodeServiceHub.ts
|
│ ├── NodeLocalStorage.ts
|
||||||
│ └── NodeSettingService.ts
|
│ └── NodeServiceHub.ts
|
||||||
├── test/
|
├── test/
|
||||||
│ ├── test-e2e-two-vaults-common.sh
|
│ ├── test-e2e-two-vaults-common.sh
|
||||||
│ ├── test-e2e-two-vaults-matrix.sh
|
│ ├── test-e2e-two-vaults-matrix.sh
|
||||||
|
|||||||
@@ -88,6 +88,27 @@ describe("CLI database preparation", () => {
|
|||||||
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("loads legacy device values and persists configuration through the service-owned store", async () => {
|
||||||
|
const localStoragePath = join(directory, ".livesync", "runtime", "local-storage.json");
|
||||||
|
await mkdir(join(directory, ".livesync", "runtime"), { recursive: true });
|
||||||
|
await writeFile(localStoragePath, JSON.stringify({ ["__proto__"]: "legacy value" }));
|
||||||
|
let legacyValue: string | null | undefined;
|
||||||
|
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||||
|
legacyValue = await core.services.setting.deviceValues.getValue("__proto__");
|
||||||
|
expect(core.services.config).toBe(core.services.setting.config);
|
||||||
|
await core.services.config.setValue("bootstrap-probe", "config value");
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await start("ls", runner);
|
||||||
|
|
||||||
|
expect(legacyValue).toBe("legacy value");
|
||||||
|
const stored = JSON.parse(await readFile(localStoragePath, "utf-8")) as Record<string, string>;
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(stored, "__proto__")).toBe(true);
|
||||||
|
expect(stored["__proto__"]).toBe("legacy value");
|
||||||
|
expect(Object.values(stored)).toContain("config value");
|
||||||
|
});
|
||||||
|
|
||||||
it.each([REMOTE_P2P, REMOTE_COUCHDB])(
|
it.each([REMOTE_P2P, REMOTE_COUCHDB])(
|
||||||
"restores configured P2P automation before the host connects (%s)",
|
"restores configured P2P automation before the host connects (%s)",
|
||||||
async (remoteType) => {
|
async (remoteType) => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
|
||||||
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
|
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
|
||||||
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
||||||
import {
|
import {
|
||||||
@@ -78,7 +77,6 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
ensureGlobalNodeLocalStorage();
|
|
||||||
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
|
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
|
||||||
|
|
||||||
/** Injectable command boundary used by CLI integration probes. */
|
/** Injectable command boundary used by CLI integration probes. */
|
||||||
@@ -400,8 +398,6 @@ export async function main(
|
|||||||
const settingsPath = options.settingsPath
|
const settingsPath = options.settingsPath
|
||||||
? path.resolve(options.settingsPath)
|
? path.resolve(options.settingsPath)
|
||||||
: path.join(databasePath, SETTINGS_FILE);
|
: path.join(databasePath, SETTINGS_FILE);
|
||||||
configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json"));
|
|
||||||
|
|
||||||
// Resolve vault path: mirror positional argument takes priority,
|
// Resolve vault path: mirror positional argument takes priority,
|
||||||
// then --vault flag, otherwise fall back to databasePath.
|
// then --vault flag, otherwise fall back to databasePath.
|
||||||
const vaultPath =
|
const vaultPath =
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"buildRun": "npm run build && npm run cli --",
|
"buildRun": "npm run build && npm run cli --",
|
||||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||||
"check": "tsc -p tsconfig.json",
|
"check": "tsc -p tsconfig.json",
|
||||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||||
|
|||||||
@@ -1,115 +1,86 @@
|
|||||||
|
import { createDeletableValueStore, sanitiseValueStoreError, ValueStoreError } from "@vrtmrz/value-store";
|
||||||
|
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
|
||||||
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
|
||||||
|
|
||||||
type LocalStorageShape = {
|
type NodeLocalStorage = ValueStore & ValueDeleter;
|
||||||
getItem(key: string): string | null;
|
|
||||||
setItem(key: string, value: string): void;
|
|
||||||
removeItem(key: string): void;
|
|
||||||
clear(): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
class PersistentNodeLocalStorage {
|
class JsonFileValueBackend {
|
||||||
private storagePath: string | undefined;
|
private values: Map<string, string>;
|
||||||
private localStore: Record<string, string> = {};
|
|
||||||
|
|
||||||
configure(storagePath: string) {
|
constructor(private readonly storagePath: string) {
|
||||||
if (this.storagePath === storagePath) {
|
this.values = this.loadValues();
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.storagePath = storagePath;
|
|
||||||
this.loadFromFile();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadFromFile() {
|
get(key: string): string | null {
|
||||||
if (!this.storagePath) {
|
return this.values.get(key) ?? null;
|
||||||
this.localStore = {};
|
}
|
||||||
|
|
||||||
|
set(key: string, value: string): void {
|
||||||
|
const nextValues = new Map(this.values);
|
||||||
|
nextValues.set(key, value);
|
||||||
|
this.writeValues(nextValues);
|
||||||
|
this.values = nextValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
if (!this.values.has(key)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const nextValues = new Map(this.values);
|
||||||
|
nextValues.delete(key);
|
||||||
|
this.writeValues(nextValues);
|
||||||
|
this.values = nextValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadValues(): Map<string, string> {
|
||||||
|
let contents: string;
|
||||||
try {
|
try {
|
||||||
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>;
|
contents = nodeFs.readFileSync(this.storagePath, "utf-8");
|
||||||
this.localStore = { ...loaded };
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error)) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
throw sanitiseValueStoreError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let document: unknown;
|
||||||
|
try {
|
||||||
|
document = JSON.parse(contents);
|
||||||
} catch {
|
} catch {
|
||||||
this.localStore = {};
|
throw new ValueStoreError("invalid-data");
|
||||||
}
|
}
|
||||||
|
if (document === null || typeof document !== "object" || Array.isArray(document)) {
|
||||||
|
throw new ValueStoreError("invalid-data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
for (const [key, value] of Object.entries(document)) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new ValueStoreError("invalid-data");
|
||||||
|
}
|
||||||
|
values.set(key, value);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
private flushToFile() {
|
private writeValues(values: ReadonlyMap<string, string>): void {
|
||||||
if (!this.storagePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
|
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
|
||||||
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8");
|
nodeFs.writeFileSync(this.storagePath, JSON.stringify(Object.fromEntries(values), null, 2), "utf-8");
|
||||||
}
|
|
||||||
|
|
||||||
getItem(key: string): string | null {
|
|
||||||
return this.localStore[key] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setItem(key: string, value: string) {
|
|
||||||
this.localStore[key] = value;
|
|
||||||
this.flushToFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
removeItem(key: string) {
|
|
||||||
if (!(key in this.localStore)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
delete this.localStore[key];
|
|
||||||
this.flushToFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.localStore = {};
|
|
||||||
this.flushToFile();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistentNodeLocalStorage = new PersistentNodeLocalStorage();
|
function isMissingFileError(error: unknown): boolean {
|
||||||
|
if (typeof error !== "object" || error === null) {
|
||||||
function createNodeLocalStorageShim(): LocalStorageShape {
|
return false;
|
||||||
return {
|
}
|
||||||
getItem(key: string) {
|
try {
|
||||||
return persistentNodeLocalStorage.getItem(key);
|
const code = Object.getOwnPropertyDescriptor(error, "code");
|
||||||
},
|
return code !== undefined && Object.prototype.hasOwnProperty.call(code, "value") && code.value === "ENOENT";
|
||||||
setItem(key: string, value: string) {
|
} catch {
|
||||||
persistentNodeLocalStorage.setItem(key, value);
|
return false;
|
||||||
},
|
|
||||||
removeItem(key: string) {
|
|
||||||
persistentNodeLocalStorage.removeItem(key);
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
persistentNodeLocalStorage.clear();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureGlobalNodeLocalStorage() {
|
|
||||||
const g = compatGlobal as unknown as Record<string, unknown>;
|
|
||||||
if (
|
|
||||||
!("localStorage" in g) ||
|
|
||||||
typeof (g.localStorage as Record<string, unknown> | undefined)?.getItem !== "function"
|
|
||||||
) {
|
|
||||||
g.localStorage = createNodeLocalStorageShim();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureNodeLocalStorage(storagePath: string) {
|
export function createNodeLocalStorage(storagePath: string): NodeLocalStorage {
|
||||||
persistentNodeLocalStorage.configure(storagePath);
|
return createDeletableValueStore(new JsonFileValueBackend(storagePath));
|
||||||
ensureGlobalNodeLocalStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNodeLocalStorageItem(key: string): string {
|
|
||||||
return persistentNodeLocalStorage.getItem(key) ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setNodeLocalStorageItem(key: string, value: string) {
|
|
||||||
persistentNodeLocalStorage.setItem(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteNodeLocalStorageItem(key: string) {
|
|
||||||
persistentNodeLocalStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearNodeLocalStorage() {
|
|
||||||
persistentNodeLocalStorage.clear();
|
|
||||||
}
|
}
|
||||||
@@ -1,58 +1,127 @@
|
|||||||
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
import { ValueStoreError } from "@vrtmrz/value-store";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||||
clearNodeLocalStorage,
|
|
||||||
configureNodeLocalStorage,
|
|
||||||
ensureGlobalNodeLocalStorage,
|
|
||||||
getNodeLocalStorageItem,
|
|
||||||
setNodeLocalStorageItem,
|
|
||||||
} from "./NodeLocalStorage";
|
|
||||||
|
|
||||||
describe("NodeLocalStorage", () => {
|
describe("NodeLocalStorage", () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearNodeLocalStorage();
|
|
||||||
for (const tempDir of tempDirs.splice(0)) {
|
for (const tempDir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists values to the configured file", () => {
|
it("loads and preserves legacy flat string data, including empty values and exact keys", async () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
|
||||||
tempDirs.push(tempDir);
|
|
||||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
|
||||||
|
|
||||||
configureNodeLocalStorage(storagePath);
|
|
||||||
setNodeLocalStorageItem("checkpoint", "42");
|
|
||||||
|
|
||||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
|
||||||
expect(saved.checkpoint).toBe("42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reloads persisted values when configured again", () => {
|
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||||
tempDirs.push(tempDir);
|
tempDirs.push(tempDir);
|
||||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||||
|
const specialKey = "__proto__";
|
||||||
|
const specialValue = "line one\n雪";
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
||||||
fs.writeFileSync(storagePath, JSON.stringify({ persisted: "value" }, null, 2), "utf-8");
|
fs.writeFileSync(storagePath, JSON.stringify({ [specialKey]: specialValue, empty: "" }), "utf-8");
|
||||||
|
|
||||||
configureNodeLocalStorage(storagePath);
|
const store = createNodeLocalStorage(storagePath);
|
||||||
|
|
||||||
expect(getNodeLocalStorageItem("persisted")).toBe("value");
|
await expect(store.getValue(specialKey)).resolves.toBe(specialValue);
|
||||||
|
await expect(store.getValue("empty", "default")).resolves.toBe("");
|
||||||
|
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||||
|
expect(saved[specialKey]).toBe(specialValue);
|
||||||
|
expect(saved.empty).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("installs a global localStorage shim backed by the same store", () => {
|
it("persists strings and objects, reloads them, and deletes keys", async () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||||
tempDirs.push(tempDir);
|
tempDirs.push(tempDir);
|
||||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||||
|
const store = createNodeLocalStorage(storagePath);
|
||||||
|
const object = { enabled: false, entries: ["value", null, 0] };
|
||||||
|
|
||||||
configureNodeLocalStorage(storagePath);
|
await store.setValue("empty", "");
|
||||||
ensureGlobalNodeLocalStorage();
|
await store.setValue("text", "stored value");
|
||||||
|
await store.setObject("object", object);
|
||||||
|
|
||||||
globalThis.localStorage.setItem("shared", "state");
|
const reloaded = createNodeLocalStorage(storagePath);
|
||||||
|
await expect(reloaded.getValue("empty", "default")).resolves.toBe("");
|
||||||
|
await expect(reloaded.getValue("text")).resolves.toBe("stored value");
|
||||||
|
await expect(reloaded.getObject("object")).resolves.toEqual(object);
|
||||||
|
|
||||||
expect(getNodeLocalStorageItem("shared")).toBe("state");
|
await reloaded.delete("text");
|
||||||
|
await expect(reloaded.getValue("text")).resolves.toBeNull();
|
||||||
|
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||||
|
expect(saved.text).toBeUndefined();
|
||||||
|
expect(saved.object).toBe(JSON.stringify(object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing file as empty and creates its parent on the first write", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||||
|
const store = createNodeLocalStorage(storagePath);
|
||||||
|
|
||||||
|
await expect(store.getValue("absent")).resolves.toBeNull();
|
||||||
|
expect(fs.existsSync(storagePath)).toBe(false);
|
||||||
|
await store.setValue("created", "value");
|
||||||
|
expect(fs.existsSync(storagePath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps separate store instances isolated by their file paths", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const first = createNodeLocalStorage(path.join(tempDir, "first", "local-storage.json"));
|
||||||
|
const second = createNodeLocalStorage(path.join(tempDir, "second", "local-storage.json"));
|
||||||
|
|
||||||
|
await first.setValue("shared-key", "first");
|
||||||
|
await second.setValue("shared-key", "second");
|
||||||
|
|
||||||
|
await expect(first.getValue("shared-key")).resolves.toBe("first");
|
||||||
|
await expect(second.getValue("shared-key")).resolves.toBe("second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces malformed and unreadable files as sanitised value-store errors", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const malformedPath = path.join(tempDir, "malformed.json");
|
||||||
|
fs.writeFileSync(malformedPath, "{ malformed", "utf-8");
|
||||||
|
|
||||||
|
let malformedError: unknown;
|
||||||
|
try {
|
||||||
|
createNodeLocalStorage(malformedPath);
|
||||||
|
} catch (error) {
|
||||||
|
malformedError = error;
|
||||||
|
}
|
||||||
|
expect(malformedError).toBeInstanceOf(ValueStoreError);
|
||||||
|
expect(malformedError).toMatchObject({ code: "invalid-data" });
|
||||||
|
expect(fs.readFileSync(malformedPath, "utf-8")).toBe("{ malformed");
|
||||||
|
|
||||||
|
const unreadablePath = path.join(tempDir, "unreadable.json");
|
||||||
|
fs.mkdirSync(unreadablePath);
|
||||||
|
expect(() => createNodeLocalStorage(unreadablePath)).toThrowError(ValueStoreError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change its visible values when a write fails", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||||
|
const store = createNodeLocalStorage(storagePath);
|
||||||
|
await store.setValue("retained", "before failure");
|
||||||
|
|
||||||
|
fs.rmSync(storagePath);
|
||||||
|
fs.mkdirSync(storagePath);
|
||||||
|
|
||||||
|
await expect(store.setValue("new", "write fails")).rejects.toBeInstanceOf(ValueStoreError);
|
||||||
|
await expect(store.getValue("retained")).resolves.toBe("before failure");
|
||||||
|
await expect(store.getValue("new")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not install or modify a global localStorage value", () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const before = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||||
|
|
||||||
|
createNodeLocalStorage(path.join(tempDir, "runtime", "local-storage.json"));
|
||||||
|
|
||||||
|
expect(Object.getOwnPropertyDescriptor(globalThis, "localStorage")).toEqual(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
import type { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
|
|
||||||
import type {
|
import type {
|
||||||
ComponentHasResult,
|
ComponentHasResult,
|
||||||
SvelteDialogManager,
|
SvelteDialogManager,
|
||||||
@@ -11,6 +10,7 @@ import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat
|
|||||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||||
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
|
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
|
||||||
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
|
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
|
||||||
|
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
|
||||||
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
||||||
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
|
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
|
||||||
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
|
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
|
||||||
@@ -21,7 +21,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
|||||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||||
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
|
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
|
||||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||||
import { NodeSettingService } from "./NodeSettingService";
|
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||||
@@ -96,11 +96,11 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
|||||||
const conflict = new InjectableConflictService(context);
|
const conflict = new InjectableConflictService(context);
|
||||||
const fileProcessing = new InjectableFileProcessingService(context);
|
const fileProcessing = new InjectableFileProcessingService(context);
|
||||||
|
|
||||||
const setting = new NodeSettingService(
|
const setting = new InjectableSettingService<T>(context, {
|
||||||
context,
|
APIService: API,
|
||||||
{ APIService: API, onDisplayLanguageChanged: setLang },
|
onDisplayLanguageChanged: setLang,
|
||||||
localStoragePath
|
deviceValues: createNodeLocalStorage(localStoragePath),
|
||||||
);
|
});
|
||||||
|
|
||||||
const appLifecycle = new NodeAppLifecycleService<T>(context, {
|
const appLifecycle = new NodeAppLifecycleService<T>(context, {
|
||||||
settingService: setting,
|
settingService: setting,
|
||||||
@@ -132,10 +132,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
|||||||
setting,
|
setting,
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = new ConfigServiceBrowserCompat<T>(context, {
|
const config = setting.config;
|
||||||
settingService: setting,
|
|
||||||
APIService: API,
|
|
||||||
});
|
|
||||||
|
|
||||||
const replicator = new InjectableReplicatorService(context, {
|
const replicator = new InjectableReplicatorService(context, {
|
||||||
settingService: setting,
|
settingService: setting,
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
|
||||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB } from "@/common/events";
|
|
||||||
import { handlers } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
|
||||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
|
||||||
import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
|
||||||
import {
|
|
||||||
configureNodeLocalStorage,
|
|
||||||
deleteNodeLocalStorageItem,
|
|
||||||
getNodeLocalStorageItem,
|
|
||||||
setNodeLocalStorageItem,
|
|
||||||
} from "./NodeLocalStorage";
|
|
||||||
|
|
||||||
export class NodeSettingService<T extends ServiceContext> extends SettingService<T> {
|
|
||||||
constructor(context: T, dependencies: SettingServiceDependencies, storagePath: string) {
|
|
||||||
super(context, dependencies);
|
|
||||||
configureNodeLocalStorage(storagePath);
|
|
||||||
this.onSettingSaved.addHandler((settings) => {
|
|
||||||
this.context.events.emitEvent(EVENT_SETTING_SAVED, settings);
|
|
||||||
return Promise.resolve(true);
|
|
||||||
});
|
|
||||||
this.onSettingLoaded.addHandler((settings) => {
|
|
||||||
this.context.events.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
|
||||||
return Promise.resolve(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected setItem(key: string, value: string) {
|
|
||||||
setNodeLocalStorageItem(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getItem(key: string): string {
|
|
||||||
return getNodeLocalStorageItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected deleteItem(key: string): void {
|
|
||||||
deleteNodeLocalStorageItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public saveData = handlers<{ saveData: (data: ObsidianLiveSyncSettings) => Promise<void> }>().binder("saveData");
|
|
||||||
public loadData = handlers<{ loadData: () => Promise<ObsidianLiveSyncSettings | undefined> }>().binder("loadData");
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,8 @@ import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFea
|
|||||||
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||||
|
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
|
||||||
|
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import {
|
import {
|
||||||
createLiveSyncBrowserServiceHub,
|
createLiveSyncBrowserServiceHub,
|
||||||
@@ -56,6 +58,7 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
|
|||||||
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
|
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
|
||||||
|
|
||||||
export interface WebAppRuntimeOptions {
|
export interface WebAppRuntimeOptions {
|
||||||
|
deviceValues?: ValueStore & ValueDeleter;
|
||||||
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
|
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
|
||||||
scheduleReload?: (delayMilliseconds: number) => void;
|
scheduleReload?: (delayMilliseconds: number) => void;
|
||||||
}
|
}
|
||||||
@@ -71,7 +74,10 @@ export class WebAppRuntime {
|
|||||||
private paneHost: P2PReplicatorPaneHost | null = null;
|
private paneHost: P2PReplicatorPaneHost | null = null;
|
||||||
private restartScheduled = false;
|
private restartScheduled = false;
|
||||||
|
|
||||||
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) {
|
constructor(
|
||||||
|
rootHandle: FileSystemDirectoryHandle,
|
||||||
|
private readonly options: WebAppRuntimeOptions = {}
|
||||||
|
) {
|
||||||
this.rootHandle = rootHandle;
|
this.rootHandle = rootHandle;
|
||||||
this.reportStatus = options.reportStatus ?? (() => {});
|
this.reportStatus = options.reportStatus ?? (() => {});
|
||||||
this.scheduleReload =
|
this.scheduleReload =
|
||||||
@@ -89,6 +95,8 @@ export class WebAppRuntime {
|
|||||||
|
|
||||||
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
|
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
|
||||||
return {
|
return {
|
||||||
|
deviceValues:
|
||||||
|
this.options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||||
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
|
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
|
||||||
settings: {
|
settings: {
|
||||||
save: async (data) => {
|
save: async (data) => {
|
||||||
@@ -219,7 +227,9 @@ export class WebAppRuntime {
|
|||||||
useCheckRemoteSize(core);
|
useCheckRemoteSize(core);
|
||||||
useRemoteConfiguration(core);
|
useRemoteConfiguration(core);
|
||||||
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
|
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
|
||||||
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
|
prepareP2PSettings: useP2PSettingsPreparation(
|
||||||
|
core.services.API.webCompatFetch.bind(core.services.API)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
this.paneHost = {
|
this.paneHost = {
|
||||||
services: core.services,
|
services: core.services,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/t
|
|||||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||||
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||||
|
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
|
||||||
|
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||||
import { unique } from "octagonal-wheels/collection";
|
import { unique } from "octagonal-wheels/collection";
|
||||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ import { translateLiveSyncMessage } from "@/common/translation";
|
|||||||
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
|
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
|
||||||
|
|
||||||
export interface WebPeerRuntimeOptions {
|
export interface WebPeerRuntimeOptions {
|
||||||
|
deviceValues?: ValueStore & ValueDeleter;
|
||||||
context?: ServiceContext;
|
context?: ServiceContext;
|
||||||
store?: SimpleStore<unknown>;
|
store?: SimpleStore<unknown>;
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
@@ -62,6 +65,7 @@ export class WebPeerRuntime {
|
|||||||
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
||||||
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
||||||
context: this.context,
|
context: this.context,
|
||||||
|
deviceValues: options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||||
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
|
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
|
||||||
settings: persistence.settings,
|
settings: persistence.settings,
|
||||||
restart: {
|
restart: {
|
||||||
@@ -103,7 +107,7 @@ export class WebPeerRuntime {
|
|||||||
await this.services.setting.loadSettings();
|
await this.services.setting.loadSettings();
|
||||||
const deviceName = this.options.deviceName?.trim();
|
const deviceName = this.options.deviceName?.trim();
|
||||||
if (deviceName) {
|
if (deviceName) {
|
||||||
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
await this.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
||||||
}
|
}
|
||||||
const opened = await this.services.database.openDatabase({
|
const opened = await this.services.database.openDatabase({
|
||||||
replicator: this.services.replicator,
|
replicator: this.services.replicator,
|
||||||
@@ -192,7 +196,7 @@ export class WebPeerRuntime {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDeviceName(): string {
|
async getDeviceName(): Promise<string> {
|
||||||
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
|
return (await this.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME)) || this.services.vault.getVaultName();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
getObjectStorageReplicatorConfigurationIdentity,
|
getObjectStorageReplicatorConfigurationIdentity,
|
||||||
} from "./replicatorConfigurationIdentity";
|
} from "./replicatorConfigurationIdentity";
|
||||||
import {
|
import {
|
||||||
|
createCouchDBJoinedDevicesResourceFactory,
|
||||||
|
createObjectStorageJoinedDevicesResourceFactory,
|
||||||
createCouchDBConnectionProbeFactory,
|
createCouchDBConnectionProbeFactory,
|
||||||
createCouchDBPreferredTweakProbeFactory,
|
createCouchDBPreferredTweakProbeFactory,
|
||||||
createCouchDBSecuritySeedResourceFactory,
|
createCouchDBSecuritySeedResourceFactory,
|
||||||
@@ -115,6 +117,9 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
||||||
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
||||||
remoteResources: {
|
remoteResources: {
|
||||||
|
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
|
||||||
|
createCouchDBJoinedDevicesResourceFactory(host)
|
||||||
|
),
|
||||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
||||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||||
createCouchDBPreferredTweakProbeFactory(host)
|
createCouchDBPreferredTweakProbeFactory(host)
|
||||||
@@ -141,6 +146,9 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||||
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
||||||
remoteResources: {
|
remoteResources: {
|
||||||
|
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
|
||||||
|
createObjectStorageJoinedDevicesResourceFactory(host)
|
||||||
|
),
|
||||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
||||||
createObjectStorageConnectionProbeFactory(host)
|
createObjectStorageConnectionProbeFactory(host)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -44,7 +44,16 @@ describe("central Replicator provider definitions", () => {
|
|||||||
it("keeps the retained remote-resource catalogue bounded", () => {
|
it("keeps the retained remote-resource catalogue bounded", () => {
|
||||||
expect
|
expect
|
||||||
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
|
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
|
||||||
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort());
|
.toEqual(
|
||||||
|
[
|
||||||
|
"connection",
|
||||||
|
"initial-fetch-fast-forward",
|
||||||
|
"joined-devices",
|
||||||
|
"preferred-tweak",
|
||||||
|
"security-seed",
|
||||||
|
"synchronisation-information",
|
||||||
|
].sort()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
|
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
|
||||||
@@ -106,8 +115,15 @@ describe("central Replicator provider definitions", () => {
|
|||||||
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
|
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
|
||||||
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
|
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
|
||||||
|
|
||||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
const requiredResources = Object.values(REMOTE_RESOURCE_KINDS).filter(
|
||||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
(kind) => kind !== REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD
|
||||||
|
);
|
||||||
|
expect(Object.keys(couchResources ?? {}).sort()).toEqual(requiredResources.sort());
|
||||||
|
expect(Object.keys(objectResources ?? {}).sort()).toEqual(requiredResources.sort());
|
||||||
|
expect(couchResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||||
|
expect(objectResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||||
|
expect(couchResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]).toBeUndefined();
|
||||||
|
expect(objectResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]).toBeUndefined();
|
||||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||||
|
getJoinedDevices: ReturnType<typeof vi.fn>;
|
||||||
|
forgetJoinedDevice: ReturnType<typeof vi.fn>;
|
||||||
closeReplication: ReturnType<typeof vi.fn>;
|
closeReplication: ReturnType<typeof vi.fn>;
|
||||||
}>,
|
}>,
|
||||||
objectStorage: [] as Array<{
|
objectStorage: [] as Array<{
|
||||||
@@ -20,6 +22,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||||
|
getJoinedDevices: ReturnType<typeof vi.fn>;
|
||||||
|
forgetJoinedDevice: ReturnType<typeof vi.fn>;
|
||||||
closeReplication: ReturnType<typeof vi.fn>;
|
closeReplication: ReturnType<typeof vi.fn>;
|
||||||
}>,
|
}>,
|
||||||
checkSyncInfo: vi.fn(async () => true),
|
checkSyncInfo: vi.fn(async () => true),
|
||||||
@@ -42,6 +46,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato
|
|||||||
getRemoteStatus = vi.fn();
|
getRemoteStatus = vi.fn();
|
||||||
getRemotePreferredTweakValues = vi.fn();
|
getRemotePreferredTweakValues = vi.fn();
|
||||||
getReplicationPBKDF2Salt = vi.fn();
|
getReplicationPBKDF2Salt = vi.fn();
|
||||||
|
getJoinedDevices = vi.fn();
|
||||||
|
forgetJoinedDevice = vi.fn();
|
||||||
closeReplication = vi.fn();
|
closeReplication = vi.fn();
|
||||||
|
|
||||||
constructor(host: unknown) {
|
constructor(host: unknown) {
|
||||||
@@ -58,6 +64,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
|||||||
getRemoteStatus = vi.fn();
|
getRemoteStatus = vi.fn();
|
||||||
getRemotePreferredTweakValues = vi.fn();
|
getRemotePreferredTweakValues = vi.fn();
|
||||||
getReplicationPBKDF2Salt = vi.fn();
|
getReplicationPBKDF2Salt = vi.fn();
|
||||||
|
getJoinedDevices = vi.fn();
|
||||||
|
forgetJoinedDevice = vi.fn();
|
||||||
closeReplication = vi.fn();
|
closeReplication = vi.fn();
|
||||||
|
|
||||||
constructor(host: unknown) {
|
constructor(host: unknown) {
|
||||||
@@ -69,10 +77,12 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createCouchDBConnectionProbeFactory,
|
createCouchDBConnectionProbeFactory,
|
||||||
|
createCouchDBJoinedDevicesResourceFactory,
|
||||||
createCouchDBPreferredTweakProbeFactory,
|
createCouchDBPreferredTweakProbeFactory,
|
||||||
createCouchDBSecuritySeedResourceFactory,
|
createCouchDBSecuritySeedResourceFactory,
|
||||||
createCouchDBSynchronisationInformationResourceFactory,
|
createCouchDBSynchronisationInformationResourceFactory,
|
||||||
createObjectStorageConnectionProbeFactory,
|
createObjectStorageConnectionProbeFactory,
|
||||||
|
createObjectStorageJoinedDevicesResourceFactory,
|
||||||
createObjectStoragePreferredTweakProbeFactory,
|
createObjectStoragePreferredTweakProbeFactory,
|
||||||
createObjectStorageSecuritySeedResourceFactory,
|
createObjectStorageSecuritySeedResourceFactory,
|
||||||
} from "./replicatorResources";
|
} from "./replicatorResources";
|
||||||
@@ -226,6 +236,65 @@ describe("replicator probe factories", () => {
|
|||||||
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
|
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
kind: "CouchDB",
|
||||||
|
remoteType: REMOTE_COUCHDB,
|
||||||
|
createFactory: createCouchDBJoinedDevicesResourceFactory,
|
||||||
|
instances: () => mocks.couchDB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "Object Storage",
|
||||||
|
remoteType: REMOTE_MINIO,
|
||||||
|
createFactory: createObjectStorageJoinedDevicesResourceFactory,
|
||||||
|
instances: () => mocks.objectStorage,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"keeps $kind joined-device operations on an owned settings snapshot",
|
||||||
|
async ({ remoteType, createFactory, instances }) => {
|
||||||
|
const host = { name: "host" };
|
||||||
|
const source = createSettings({ remoteType });
|
||||||
|
const snapshot = { ...source };
|
||||||
|
const factory = createFactory(host as never);
|
||||||
|
const resource = await factory(source);
|
||||||
|
const nextResource = await factory(source);
|
||||||
|
const [replicator, nextReplicator] = instances();
|
||||||
|
expect(instances()).toHaveLength(2);
|
||||||
|
expect(replicator.host).toBe(host);
|
||||||
|
expect(replicator.getJoinedDevices).not.toHaveBeenCalled();
|
||||||
|
expect(replicator.forgetJoinedDevice).not.toHaveBeenCalled();
|
||||||
|
const devices = {
|
||||||
|
milestonePresent: true,
|
||||||
|
locked: false,
|
||||||
|
cleaned: false,
|
||||||
|
devices: [{ id: "other", accepted: true }],
|
||||||
|
};
|
||||||
|
replicator.getJoinedDevices.mockResolvedValue(devices);
|
||||||
|
replicator.forgetJoinedDevice.mockResolvedValue("conflict");
|
||||||
|
|
||||||
|
source.couchDB_DBNAME = "changed-vault";
|
||||||
|
source.bucket = "changed-bucket";
|
||||||
|
await expect(resource.read()).resolves.toBe(devices);
|
||||||
|
await expect(resource.forget("other")).resolves.toBe("conflict");
|
||||||
|
expect(replicator.getJoinedDevices).toHaveBeenCalledWith(snapshot);
|
||||||
|
expect(replicator.getJoinedDevices.mock.calls[0][0]).not.toBe(source);
|
||||||
|
expect(replicator.forgetJoinedDevice).toHaveBeenCalledWith(snapshot, "other");
|
||||||
|
|
||||||
|
const failure = new Error("remote unavailable");
|
||||||
|
replicator.getJoinedDevices.mockRejectedValue(failure);
|
||||||
|
await expect(resource.read()).rejects.toBe(failure);
|
||||||
|
replicator.forgetJoinedDevice.mockRejectedValue(failure);
|
||||||
|
await expect(resource.forget("other")).rejects.toBe(failure);
|
||||||
|
const disposal = resource.dispose();
|
||||||
|
expect(resource.dispose()).toBe(disposal);
|
||||||
|
await disposal;
|
||||||
|
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||||
|
expect(nextReplicator.closeReplication).not.toHaveBeenCalled();
|
||||||
|
await nextResource.dispose();
|
||||||
|
expect(nextReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it("shares one successful asynchronous disposal promise for every probe kind", async () => {
|
it("shares one successful asynchronous disposal promise for every probe kind", async () => {
|
||||||
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
|
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
|
||||||
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
|
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
|
||||||
|
|||||||
@@ -14,3 +14,8 @@ export {
|
|||||||
type SecuritySeedResourceHost,
|
type SecuritySeedResourceHost,
|
||||||
} from "./securitySeed";
|
} from "./securitySeed";
|
||||||
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
|
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
|
||||||
|
|
||||||
|
export {
|
||||||
|
createCouchDBJoinedDevicesResourceFactory,
|
||||||
|
createObjectStorageJoinedDevicesResourceFactory,
|
||||||
|
} from "./joinedDevices";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import type {
|
||||||
|
JoinedDevicesResource,
|
||||||
|
JoinedDevicesResourceFactory,
|
||||||
|
JoinedDevicesSnapshot,
|
||||||
|
ForgetJoinedDeviceResult,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
|
import {
|
||||||
|
LiveSyncCouchDBReplicator,
|
||||||
|
type LiveSyncCouchDBReplicatorEnv,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||||
|
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||||
|
import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
|
||||||
|
|
||||||
|
interface JoinedDevicesReplicator extends ResourceReplicator {
|
||||||
|
getJoinedDevices(setting: RemoteDBSettings): Promise<JoinedDevicesSnapshot>;
|
||||||
|
forgetJoinedDevice(setting: RemoteDBSettings, deviceId: string): Promise<ForgetJoinedDeviceResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResource(replicator: JoinedDevicesReplicator, snapshot: RemoteDBSettings): JoinedDevicesResource {
|
||||||
|
return {
|
||||||
|
read: () => replicator.getJoinedDevices(snapshot),
|
||||||
|
forget: (deviceId) => replicator.forgetJoinedDevice(snapshot, deviceId),
|
||||||
|
dispose: createReplicatorDisposer(replicator),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCouchDBJoinedDevicesResourceFactory(
|
||||||
|
host: LiveSyncCouchDBReplicatorEnv
|
||||||
|
): JoinedDevicesResourceFactory {
|
||||||
|
return (setting) =>
|
||||||
|
Promise.resolve(createResource(new LiveSyncCouchDBReplicator(host), snapshotRemoteSettings(setting)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createObjectStorageJoinedDevicesResourceFactory(
|
||||||
|
host: LiveSyncCouchDBReplicatorEnv
|
||||||
|
): JoinedDevicesResourceFactory {
|
||||||
|
return (setting) =>
|
||||||
|
Promise.resolve(createResource(new LiveSyncJournalReplicator(host), snapshotRemoteSettings(setting)));
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
@@ -18,17 +19,7 @@ import {
|
|||||||
} from "./turnSettingsPrivacy";
|
} from "./turnSettingsPrivacy";
|
||||||
|
|
||||||
class MemorySettingService extends SettingService {
|
class MemorySettingService extends SettingService {
|
||||||
readonly items = new Map<string, string>();
|
|
||||||
saved?: ObsidianLiveSyncSettings;
|
saved?: ObsidianLiveSyncSettings;
|
||||||
protected setItem(key: string, value: string) {
|
|
||||||
this.items.set(key, value);
|
|
||||||
}
|
|
||||||
protected getItem(key: string) {
|
|
||||||
return this.items.get(key) ?? "";
|
|
||||||
}
|
|
||||||
protected deleteItem(key: string) {
|
|
||||||
this.items.delete(key);
|
|
||||||
}
|
|
||||||
protected saveData(settings: ObsidianLiveSyncSettings) {
|
protected saveData(settings: ObsidianLiveSyncSettings) {
|
||||||
this.saved = structuredClone(settings);
|
this.saved = structuredClone(settings);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -70,6 +61,7 @@ describe("managed TURN settings privacy", () => {
|
|||||||
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
|
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
|
||||||
current.remoteConfigurations.managed.uri = originalURI;
|
current.remoteConfigurations.managed.uri = originalURI;
|
||||||
const service = new MemorySettingService(new ServiceContext(), {
|
const service = new MemorySettingService(new ServiceContext(), {
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
APIService: {
|
APIService: {
|
||||||
getSystemVaultName: () => "test-vault",
|
getSystemVaultName: () => "test-vault",
|
||||||
getAppID: () => "test-app",
|
getAppID: () => "test-app",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { ValueStoreError } from "@vrtmrz/value-store";
|
||||||
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import {
|
import {
|
||||||
AcceptedStatus,
|
AcceptedStatus,
|
||||||
@@ -33,6 +34,14 @@
|
|||||||
let settings = $state<P2PSyncSetting>(initialSettings);
|
let settings = $state<P2PSyncSetting>(initialSettings);
|
||||||
|
|
||||||
let deviceName = $state<string>("");
|
let deviceName = $state<string>("");
|
||||||
|
let deviceNameReady = $state(false);
|
||||||
|
let dialogStatusReady = $state(false);
|
||||||
|
type StorageErrorKey = "deviceNameLoad" | "dialogStatusLoad" | "deviceNameWrite" | "dialogStatusWrite";
|
||||||
|
let storageErrors = $state<Partial<Record<StorageErrorKey, string>>>({});
|
||||||
|
let mounted = false;
|
||||||
|
let deviceNameLoadSequence = 0;
|
||||||
|
let deviceNameWriteQueue: Promise<void> = Promise.resolve();
|
||||||
|
let dialogStatusWriteQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
|
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
|
||||||
let eRelay = $state<string>(initialSettings.P2P_relays);
|
let eRelay = $state<string>(initialSettings.P2P_relays);
|
||||||
@@ -68,6 +77,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function saveAndApply() {
|
async function saveAndApply() {
|
||||||
|
const savedDeviceName = eDeviceName;
|
||||||
// const newSettings = {
|
// const newSettings = {
|
||||||
// ...currentSettings(),
|
// ...currentSettings(),
|
||||||
// P2P_Enabled: eP2PEnabled,
|
// P2P_Enabled: eP2PEnabled,
|
||||||
@@ -92,8 +102,39 @@
|
|||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
|
try {
|
||||||
deviceName = eDeviceName;
|
await queueDeviceNameWrite(savedDeviceName);
|
||||||
|
if (!mounted) return;
|
||||||
|
deviceName = savedDeviceName;
|
||||||
|
clearStorageError("deviceNameWrite");
|
||||||
|
} catch (error) {
|
||||||
|
setStorageError("deviceNameWrite", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStorageError(key: StorageErrorKey, error: unknown) {
|
||||||
|
if (mounted) {
|
||||||
|
storageErrors = {
|
||||||
|
...storageErrors,
|
||||||
|
[key]: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStorageError(key: StorageErrorKey) {
|
||||||
|
if (!mounted) return;
|
||||||
|
const nextErrors = { ...storageErrors };
|
||||||
|
delete nextErrors[key];
|
||||||
|
storageErrors = nextErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueDeviceNameWrite(name: string): Promise<void> {
|
||||||
|
const write = deviceNameWriteQueue.catch(() => undefined).then(async () => {
|
||||||
|
if (!mounted) return;
|
||||||
|
await services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, name);
|
||||||
|
});
|
||||||
|
deviceNameWriteQueue = write;
|
||||||
|
return write;
|
||||||
}
|
}
|
||||||
async function revert() {
|
async function revert() {
|
||||||
eP2PEnabled = settings.P2P_Enabled;
|
eP2PEnabled = settings.P2P_Enabled;
|
||||||
@@ -110,10 +151,7 @@
|
|||||||
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||||
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
|
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
|
||||||
if (force) {
|
if (force) {
|
||||||
const initDeviceName =
|
void loadDeviceName(true).catch(() => undefined);
|
||||||
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
|
|
||||||
deviceName = initDeviceName;
|
|
||||||
eDeviceName = initDeviceName;
|
|
||||||
}
|
}
|
||||||
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
|
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
|
||||||
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
|
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
|
||||||
@@ -129,6 +167,7 @@
|
|||||||
settings = d;
|
settings = d;
|
||||||
};
|
};
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
const r = events.onEvent("setting-saved", async (d) => {
|
const r = events.onEvent("setting-saved", async (d) => {
|
||||||
applyLoadSettings(d, false);
|
applyLoadSettings(d, false);
|
||||||
closeServer();
|
closeServer();
|
||||||
@@ -143,15 +182,52 @@
|
|||||||
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||||
replicatorInfo = status;
|
replicatorInfo = status;
|
||||||
});
|
});
|
||||||
applyLoadSettings(currentSettings(), true);
|
applyLoadSettings(currentSettings(), false);
|
||||||
|
void loadInitialPreferences().catch(() => undefined);
|
||||||
host.p2p.diagnostics.requestStatus();
|
host.p2p.diagnostics.requestStatus();
|
||||||
return () => {
|
return () => {
|
||||||
|
mounted = false;
|
||||||
r();
|
r();
|
||||||
rx();
|
rx();
|
||||||
r2();
|
r2();
|
||||||
r3();
|
r3();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadInitialPreferences() {
|
||||||
|
const deviceNameLoad = loadDeviceName(true).catch(() => undefined);
|
||||||
|
const dialogStatusLoad = readDialogStatus().then(
|
||||||
|
(dialogStatus) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
isNoticeOpened = dialogStatus.notice ?? true;
|
||||||
|
isSettingOpened = dialogStatus.setting ?? true;
|
||||||
|
dialogStatusReady = true;
|
||||||
|
clearStorageError("dialogStatusLoad");
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
setStorageError("dialogStatusLoad", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await Promise.all([deviceNameLoad, dialogStatusLoad]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeviceName(force: boolean) {
|
||||||
|
const request = ++deviceNameLoadSequence;
|
||||||
|
try {
|
||||||
|
const savedDeviceName = await services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME, "");
|
||||||
|
if (!mounted || request !== deviceNameLoadSequence) return;
|
||||||
|
const initialDeviceName = savedDeviceName;
|
||||||
|
if (force || !isDeviceNameModified) {
|
||||||
|
deviceName = initialDeviceName;
|
||||||
|
eDeviceName = initialDeviceName;
|
||||||
|
}
|
||||||
|
deviceNameReady = true;
|
||||||
|
clearStorageError("deviceNameLoad");
|
||||||
|
} catch (error) {
|
||||||
|
if (mounted && request === deviceNameLoadSequence) setStorageError("deviceNameLoad", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
let isConnected = $derived.by(() => {
|
let isConnected = $derived.by(() => {
|
||||||
return serverInfo?.isConnected ?? false;
|
return serverInfo?.isConnected ?? false;
|
||||||
});
|
});
|
||||||
@@ -234,26 +310,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialDialogStatusKey = `p2p-dialog-status`;
|
const initialDialogStatusKey = `p2p-dialog-status`;
|
||||||
const getDialogStatus = () => {
|
const readDialogStatus = async (): Promise<{ notice?: boolean; setting?: boolean }> => {
|
||||||
try {
|
try {
|
||||||
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
|
const initialDialogStatus = await services.config.getObject(initialDialogStatusKey);
|
||||||
notice?: boolean;
|
if (typeof initialDialogStatus !== "object" || initialDialogStatus === null || Array.isArray(initialDialogStatus)) {
|
||||||
setting?: boolean;
|
return {};
|
||||||
|
}
|
||||||
|
const status = initialDialogStatus as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...(typeof status.notice === "boolean" ? { notice: status.notice } : {}),
|
||||||
|
...(typeof status.setting === "boolean" ? { setting: status.setting } : {}),
|
||||||
};
|
};
|
||||||
return initialDialogStatus;
|
} catch (error) {
|
||||||
} catch {
|
if (error instanceof ValueStoreError && error.code === "invalid-data") {
|
||||||
return {};
|
// Invalid optional UI state is recovered with the historical defaults.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const initialDialogStatus = getDialogStatus();
|
let isNoticeOpened = $state<boolean>(true);
|
||||||
let isNoticeOpened = $state<boolean>(initialDialogStatus.notice ?? true);
|
let isSettingOpened = $state<boolean>(true);
|
||||||
let isSettingOpened = $state<boolean>(initialDialogStatus.setting ?? true);
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
if (!mounted || !dialogStatusReady) return;
|
||||||
const dialogStatus = {
|
const dialogStatus = {
|
||||||
notice: isNoticeOpened,
|
notice: isNoticeOpened,
|
||||||
setting: isSettingOpened,
|
setting: isSettingOpened,
|
||||||
};
|
};
|
||||||
services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
|
const write = dialogStatusWriteQueue.catch(() => undefined).then(async () => {
|
||||||
|
if (!mounted) return;
|
||||||
|
await services.config.setObject(initialDialogStatusKey, dialogStatus);
|
||||||
|
});
|
||||||
|
dialogStatusWriteQueue = write;
|
||||||
|
void write.then(
|
||||||
|
() => clearStorageError("dialogStatusWrite"),
|
||||||
|
(error: unknown) => setStorageError("dialogStatusWrite", error)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
let isObsidian = $derived.by(() => {
|
let isObsidian = $derived.by(() => {
|
||||||
return services.API.getPlatform() === "obsidian";
|
return services.API.getPlatform() === "obsidian";
|
||||||
@@ -261,6 +353,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
|
{#each Object.values(storageErrors) as storageError}
|
||||||
|
<p role="alert">{storageError}</p>
|
||||||
|
{/each}
|
||||||
<h1>{_msg("Peer to Peer Replicator")}</h1>
|
<h1>{_msg("Peer to Peer Replicator")}</h1>
|
||||||
<details bind:open={isNoticeOpened}>
|
<details bind:open={isNoticeOpened}>
|
||||||
<summary>{_msg("P2P.Note.Summary")}</summary>
|
<summary>{_msg("P2P.Note.Summary")}</summary>
|
||||||
@@ -345,6 +440,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="iphone-16"
|
placeholder="iphone-16"
|
||||||
bind:value={eDeviceName}
|
bind:value={eDeviceName}
|
||||||
|
disabled={!deviceNameReady}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -383,7 +479,7 @@
|
|||||||
</tr> -->
|
</tr> -->
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}
|
<button disabled={!isAnyModified || !deviceNameReady} class="button mod-cta" onclick={saveAndApply}
|
||||||
>{_msg("Save and Apply")}</button
|
>{_msg("Save and Apply")}</button
|
||||||
>
|
>
|
||||||
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
|
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps";
|
import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps";
|
||||||
|
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||||
import {
|
import {
|
||||||
REVIEW_HARNESS_SCENARIOS,
|
REVIEW_HARNESS_SCENARIOS,
|
||||||
type ReviewHarnessScenarioId,
|
type ReviewHarnessScenarioId,
|
||||||
@@ -101,7 +102,7 @@ export class ReviewHarnessView extends ItemView {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
this.addActionButton(actions, "Restart and return", "review-harness-restart", () =>
|
this.addActionButton(actions, "Restart and return", "review-harness-restart", () =>
|
||||||
this.controller.prepareCompatibilityReviewRestart()
|
this.controller.prepareCompatibilityReviewRestart().catch((error) => Logger(error, LOG_LEVEL_NOTICE))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export interface ReviewHarnessRuntime {
|
|||||||
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
||||||
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||||
runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||||
readContinuation(): string | null;
|
readContinuation(): Promise<string | null>;
|
||||||
writeContinuation(value: string): void;
|
writeContinuation(value: string): Promise<void>;
|
||||||
deleteContinuation(): void;
|
deleteContinuation(): Promise<void>;
|
||||||
restart(): void;
|
restart(): void;
|
||||||
reportError(error: unknown): void;
|
reportError(error: unknown): void;
|
||||||
copyText(value: string): Promise<void>;
|
copyText(value: string): Promise<void>;
|
||||||
@@ -68,6 +68,7 @@ export class ReviewHarnessController {
|
|||||||
private current: ReviewHarnessScenarioId | null = null;
|
private current: ReviewHarnessScenarioId | null = null;
|
||||||
private resumedRequestId: string | null = null;
|
private resumedRequestId: string | null = null;
|
||||||
private continuationError: string | null = null;
|
private continuationError: string | null = null;
|
||||||
|
private continuationConsumption: Promise<void> | undefined;
|
||||||
|
|
||||||
constructor(private readonly runtime: ReviewHarnessRuntime) {}
|
constructor(private readonly runtime: ReviewHarnessRuntime) {}
|
||||||
|
|
||||||
@@ -102,10 +103,20 @@ export class ReviewHarnessController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
consumeContinuation(): void {
|
consumeContinuation(): Promise<void> {
|
||||||
const serialised = this.runtime.readContinuation();
|
if (this.continuationConsumption) return this.continuationConsumption;
|
||||||
|
const consumption = this.consumeContinuationFromStore();
|
||||||
|
const trackedConsumption = consumption.finally(() => {
|
||||||
|
if (this.continuationConsumption === trackedConsumption) this.continuationConsumption = undefined;
|
||||||
|
});
|
||||||
|
this.continuationConsumption = trackedConsumption;
|
||||||
|
return trackedConsumption;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumeContinuationFromStore(): Promise<void> {
|
||||||
|
const serialised = await this.runtime.readContinuation();
|
||||||
if (!serialised) return;
|
if (!serialised) return;
|
||||||
this.runtime.deleteContinuation();
|
await this.runtime.deleteContinuation();
|
||||||
const parsed = parsePendingReviewRun(serialised);
|
const parsed = parsePendingReviewRun(serialised);
|
||||||
if (!parsed.pendingRun) {
|
if (!parsed.pendingRun) {
|
||||||
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
|
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
|
||||||
@@ -232,7 +243,7 @@ export class ReviewHarnessController {
|
|||||||
this.record("scenario-failed", id);
|
this.record("scenario-failed", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareCompatibilityReviewRestart(): void {
|
async prepareCompatibilityReviewRestart(): Promise<void> {
|
||||||
const requestedAt = this.runtime.now().toISOString();
|
const requestedAt = this.runtime.now().toISOString();
|
||||||
const pending = {
|
const pending = {
|
||||||
formatVersion: 1,
|
formatVersion: 1,
|
||||||
@@ -241,7 +252,7 @@ export class ReviewHarnessController {
|
|||||||
stage: "awaiting-restart",
|
stage: "awaiting-restart",
|
||||||
requestedAt,
|
requestedAt,
|
||||||
} as const;
|
} as const;
|
||||||
this.runtime.writeContinuation(JSON.stringify(pending));
|
await this.runtime.writeContinuation(JSON.stringify(pending));
|
||||||
this.results["compatibility-review"] = {
|
this.results["compatibility-review"] = {
|
||||||
status: "waiting-for-user",
|
status: "waiting-for-user",
|
||||||
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
|
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
|
||||||
|
|||||||
@@ -90,14 +90,14 @@ function createRuntime(): ReviewHarnessRuntime & {
|
|||||||
detail: "File content hash measurements completed.",
|
detail: "File content hash measurements completed.",
|
||||||
observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"],
|
observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"],
|
||||||
})),
|
})),
|
||||||
readContinuation() {
|
async readContinuation() {
|
||||||
return this.continuation;
|
return this.continuation;
|
||||||
},
|
},
|
||||||
writeContinuation(value) {
|
async writeContinuation(value) {
|
||||||
this.events.push("write");
|
this.events.push("write");
|
||||||
this.continuation = value;
|
this.continuation = value;
|
||||||
},
|
},
|
||||||
deleteContinuation() {
|
async deleteContinuation() {
|
||||||
this.events.push("delete");
|
this.events.push("delete");
|
||||||
this.continuation = null;
|
this.continuation = null;
|
||||||
},
|
},
|
||||||
@@ -251,7 +251,7 @@ describe("ReviewHarnessController", () => {
|
|||||||
expect(controller.snapshot().running).toBe(false);
|
expect(controller.snapshot().running).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
|
it("deletes a one-shot continuation before exposing the resumed guided step", async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
runtime.continuation = JSON.stringify({
|
runtime.continuation = JSON.stringify({
|
||||||
formatVersion: 1,
|
formatVersion: 1,
|
||||||
@@ -262,7 +262,7 @@ describe("ReviewHarnessController", () => {
|
|||||||
});
|
});
|
||||||
const controller = new ReviewHarnessController(runtime);
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
controller.consumeContinuation();
|
await controller.consumeContinuation();
|
||||||
|
|
||||||
expect(runtime.events).toEqual(["delete"]);
|
expect(runtime.events).toEqual(["delete"]);
|
||||||
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
|
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
|
||||||
@@ -271,7 +271,7 @@ describe("ReviewHarnessController", () => {
|
|||||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not copy rejected continuation values into the report", () => {
|
it("does not copy rejected continuation values into the report", async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
runtime.continuation = JSON.stringify({
|
runtime.continuation = JSON.stringify({
|
||||||
formatVersion: 1,
|
formatVersion: 1,
|
||||||
@@ -282,7 +282,7 @@ describe("ReviewHarnessController", () => {
|
|||||||
});
|
});
|
||||||
const controller = new ReviewHarnessController(runtime);
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
controller.consumeContinuation();
|
await controller.consumeContinuation();
|
||||||
|
|
||||||
expect(controller.snapshot().continuationError).toContain("private-vault");
|
expect(controller.snapshot().continuationError).toContain("private-vault");
|
||||||
expect(controller.snapshot().results["compatibility-review"].detail).toBe(
|
expect(controller.snapshot().results["compatibility-review"].detail).toBe(
|
||||||
@@ -292,7 +292,7 @@ describe("ReviewHarnessController", () => {
|
|||||||
expect(controller.createReport()).not.toContain("secret-note.md");
|
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not accept an injected request ID which could enter the report transcript", () => {
|
it("does not accept an injected request ID which could enter the report transcript", async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
runtime.continuation = JSON.stringify({
|
runtime.continuation = JSON.stringify({
|
||||||
formatVersion: 1,
|
formatVersion: 1,
|
||||||
@@ -303,18 +303,18 @@ describe("ReviewHarnessController", () => {
|
|||||||
});
|
});
|
||||||
const controller = new ReviewHarnessController(runtime);
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
controller.consumeContinuation();
|
await controller.consumeContinuation();
|
||||||
|
|
||||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
|
||||||
expect(controller.createReport()).not.toContain("private-vault");
|
expect(controller.createReport()).not.toContain("private-vault");
|
||||||
expect(controller.createReport()).not.toContain("secret-note.md");
|
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists the fixed continuation before requesting restart", () => {
|
it("persists the fixed continuation before requesting restart", async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
const controller = new ReviewHarnessController(runtime);
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
controller.prepareCompatibilityReviewRestart();
|
await controller.prepareCompatibilityReviewRestart();
|
||||||
|
|
||||||
expect(runtime.events).toEqual(["write", "restart"]);
|
expect(runtime.events).toEqual(["write", "restart"]);
|
||||||
expect(JSON.parse(runtime.continuation!)).toMatchObject({
|
expect(JSON.parse(runtime.continuation!)).toMatchObject({
|
||||||
@@ -324,6 +324,135 @@ describe("ReviewHarnessController", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("waits for the continuation save before requesting restart", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
let markSaveStarted!: () => void;
|
||||||
|
const saveStarted = new Promise<void>((resolve) => {
|
||||||
|
markSaveStarted = resolve;
|
||||||
|
});
|
||||||
|
let finishSave!: () => void;
|
||||||
|
const saving = new Promise<void>((resolve) => {
|
||||||
|
finishSave = resolve;
|
||||||
|
});
|
||||||
|
runtime.writeContinuation = vi.fn(async (value) => {
|
||||||
|
runtime.events.push("write-started");
|
||||||
|
markSaveStarted();
|
||||||
|
await saving;
|
||||||
|
runtime.continuation = value;
|
||||||
|
runtime.events.push("write-saved");
|
||||||
|
});
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
const preparing = controller.prepareCompatibilityReviewRestart();
|
||||||
|
await saveStarted;
|
||||||
|
|
||||||
|
expect(runtime.events).toEqual(["write-started"]);
|
||||||
|
expect(runtime.restart).not.toHaveBeenCalled();
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||||
|
|
||||||
|
finishSave();
|
||||||
|
await preparing;
|
||||||
|
|
||||||
|
expect(runtime.events).toEqual(["write-started", "write-saved", "restart"]);
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the guided review retryable when saving its continuation fails", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
await controller.runScenario("compatibility-review");
|
||||||
|
runtime.writeContinuation = vi.fn(async () => {
|
||||||
|
runtime.events.push("write-failed");
|
||||||
|
throw new Error("continuation save failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.prepareCompatibilityReviewRestart()).rejects.toThrow("continuation save failed");
|
||||||
|
|
||||||
|
expect(runtime.events).toEqual(["write-failed"]);
|
||||||
|
expect(runtime.restart).not.toHaveBeenCalled();
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for one continuation deletion before exposing it and coalesces concurrent consumption", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
runtime.continuation = JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
|
||||||
|
scenarioId: "compatibility-review",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||||
|
});
|
||||||
|
let markDeleteStarted!: () => void;
|
||||||
|
const deleteStarted = new Promise<void>((resolve) => {
|
||||||
|
markDeleteStarted = resolve;
|
||||||
|
});
|
||||||
|
let finishDelete!: () => void;
|
||||||
|
const deleting = new Promise<void>((resolve) => {
|
||||||
|
finishDelete = resolve;
|
||||||
|
});
|
||||||
|
runtime.readContinuation = vi.fn(async () => {
|
||||||
|
runtime.events.push("read");
|
||||||
|
return runtime.continuation;
|
||||||
|
});
|
||||||
|
runtime.deleteContinuation = vi.fn(async () => {
|
||||||
|
runtime.events.push("delete-started");
|
||||||
|
markDeleteStarted();
|
||||||
|
await deleting;
|
||||||
|
runtime.continuation = null;
|
||||||
|
runtime.events.push("delete-finished");
|
||||||
|
});
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
const firstConsumption = controller.consumeContinuation();
|
||||||
|
const duplicateConsumption = controller.consumeContinuation();
|
||||||
|
await deleteStarted;
|
||||||
|
|
||||||
|
expect(duplicateConsumption).toBe(firstConsumption);
|
||||||
|
expect(runtime.events).toEqual(["read", "delete-started"]);
|
||||||
|
expect(controller.snapshot().resumedRequestId).toBeNull();
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||||
|
|
||||||
|
finishDelete();
|
||||||
|
await firstConsumption;
|
||||||
|
|
||||||
|
expect(runtime.events).toEqual(["read", "delete-started", "delete-finished"]);
|
||||||
|
expect(runtime.readContinuation).toHaveBeenCalledOnce();
|
||||||
|
expect(runtime.deleteContinuation).toHaveBeenCalledOnce();
|
||||||
|
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a failed continuation deletion available for retry", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
runtime.continuation = JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
|
||||||
|
scenarioId: "compatibility-review",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||||
|
});
|
||||||
|
runtime.deleteContinuation = vi
|
||||||
|
.fn<ReviewHarnessRuntime["deleteContinuation"]>()
|
||||||
|
.mockRejectedValueOnce(new Error("continuation delete failed"))
|
||||||
|
.mockImplementation(async () => {
|
||||||
|
runtime.events.push("delete");
|
||||||
|
runtime.continuation = null;
|
||||||
|
});
|
||||||
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|
||||||
|
await expect(controller.consumeContinuation()).rejects.toThrow("continuation delete failed");
|
||||||
|
|
||||||
|
expect(runtime.continuation).not.toBeNull();
|
||||||
|
expect(controller.snapshot().resumedRequestId).toBeNull();
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||||
|
|
||||||
|
await controller.consumeContinuation();
|
||||||
|
|
||||||
|
expect(runtime.continuation).toBeNull();
|
||||||
|
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||||
|
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||||
|
});
|
||||||
|
|
||||||
it("passes only after the actual compatibility review clears its pause", async () => {
|
it("passes only after the actual compatibility review clears its pause", async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
const controller = new ReviewHarnessController(runtime);
|
const controller = new ReviewHarnessController(runtime);
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
|
|||||||
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
||||||
settingTab!: ObsidianLiveSyncSettingTab;
|
settingTab!: ObsidianLiveSyncSettingTab;
|
||||||
|
|
||||||
_everyOnloadAfterLoadSettings(): Promise<boolean> {
|
async _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||||
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
|
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
|
||||||
this.settingTab.reloadAllSettings(true);
|
await this.settingTab.loadSettings();
|
||||||
this.plugin.addSettingTab(this.settingTab);
|
this.plugin.addSettingTab(this.settingTab);
|
||||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
|
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
|
||||||
|
|
||||||
return Promise.resolve(true);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
openSetting() {
|
openSetting() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const settingTabState = vi.hoisted(() => ({
|
const settingTabState = vi.hoisted(() => ({
|
||||||
callOrder: [] as string[],
|
callOrder: [] as string[],
|
||||||
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
|
loadSettings: vi.fn<() => Promise<void>>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const eventHubState = vi.hoisted(() => ({
|
const eventHubState = vi.hoisted(() => ({
|
||||||
@@ -11,9 +11,9 @@ const eventHubState = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
|
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
|
||||||
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
|
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
|
||||||
reloadAllSettings(skipUpdate?: boolean) {
|
async loadSettings() {
|
||||||
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
|
await settingTabState.loadSettings();
|
||||||
settingTabState.reloadAllSettings(skipUpdate);
|
settingTabState.callOrder.push("settings-loaded");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -65,7 +65,7 @@ function createModuleHarness() {
|
|||||||
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
settingTabState.callOrder.length = 0;
|
settingTabState.callOrder.length = 0;
|
||||||
settingTabState.reloadAllSettings.mockClear();
|
settingTabState.loadSettings.mockReset().mockResolvedValue(undefined);
|
||||||
eventHubState.onEvent.mockClear();
|
eventHubState.onEvent.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,13 +79,29 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds the setting editor without requesting a render before registration", async () => {
|
it("seeds the setting editor without requesting a render before registration", async () => {
|
||||||
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
|
const { initialisationHandler, settingsLoadedHandler, plugin } = createModuleHarness();
|
||||||
|
let finishRead!: () => void;
|
||||||
|
settingTabState.loadSettings.mockReturnValueOnce(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishRead = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
const handler = settingsLoadedHandler() ?? initialisationHandler();
|
const handler = settingsLoadedHandler() ?? initialisationHandler();
|
||||||
|
|
||||||
expect(handler).toBeTypeOf("function");
|
expect(handler).toBeTypeOf("function");
|
||||||
await handler!();
|
const loading = handler!();
|
||||||
|
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||||
|
finishRead();
|
||||||
|
await loading;
|
||||||
|
|
||||||
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
|
expect(settingTabState.loadSettings).toHaveBeenCalledOnce();
|
||||||
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
|
expect(settingTabState.callOrder).toEqual(["settings-loaded", "add-setting-tab"]);
|
||||||
|
});
|
||||||
|
it("does not register an editor when reading device values fails", async () => {
|
||||||
|
const { settingsLoadedHandler, plugin } = createModuleHarness();
|
||||||
|
const failure = new Error("device storage unavailable");
|
||||||
|
settingTabState.loadSettings.mockRejectedValueOnce(failure);
|
||||||
|
await expect(settingsLoadedHandler()!()).rejects.toBe(failure);
|
||||||
|
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+8
-6
@@ -1,3 +1,4 @@
|
|||||||
|
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
|
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
|
||||||
@@ -158,6 +159,7 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
|
|||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
setting: {
|
setting: {
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
getDeviceAndVaultName: vi.fn(() => ""),
|
getDeviceAndVaultName: vi.fn(() => ""),
|
||||||
saveSettingData: vi.fn(async () => undefined),
|
saveSettingData: vi.fn(async () => undefined),
|
||||||
},
|
},
|
||||||
@@ -457,7 +459,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
|||||||
expect(runtime.pageCleanup).toHaveBeenCalledOnce();
|
expect(runtime.pageCleanup).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => {
|
it("rebuilds the catalogue when an externally loaded setting changes page visibility", async () => {
|
||||||
const tab = createSettingsTab();
|
const tab = createSettingsTab();
|
||||||
const changeLog = findPage(tab, "Change Log");
|
const changeLog = findPage(tab, "Change Log");
|
||||||
if (!changeLog.page) {
|
if (!changeLog.page) {
|
||||||
@@ -466,7 +468,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
|||||||
changeLog.page().display();
|
changeLog.page().display();
|
||||||
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
|
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
|
||||||
|
|
||||||
tab.requestReload();
|
await tab.requestReload();
|
||||||
|
|
||||||
expect(tab.update).toHaveBeenCalledOnce();
|
expect(tab.update).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
@@ -480,7 +482,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
|||||||
expect(tab.update).toHaveBeenCalledOnce();
|
expect(tab.update).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rebuilds translated catalogue names when the display language changes externally", () => {
|
it("rebuilds translated catalogue names when the display language changes externally", async () => {
|
||||||
const tab = createSettingsTab();
|
const tab = createSettingsTab();
|
||||||
const changeLog = findPage(tab, "Change Log");
|
const changeLog = findPage(tab, "Change Log");
|
||||||
if (!changeLog.page) {
|
if (!changeLog.page) {
|
||||||
@@ -489,12 +491,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
|||||||
changeLog.page().display();
|
changeLog.page().display();
|
||||||
tab.core.settings.displayLanguage = "ja";
|
tab.core.settings.displayLanguage = "ja";
|
||||||
|
|
||||||
tab.requestReload();
|
await tab.requestReload();
|
||||||
|
|
||||||
expect(tab.update).toHaveBeenCalledOnce();
|
expect(tab.update).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => {
|
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", async () => {
|
||||||
const tab = createSettingsTab();
|
const tab = createSettingsTab();
|
||||||
const changeLog = findPage(tab, "Change Log");
|
const changeLog = findPage(tab, "Change Log");
|
||||||
if (!changeLog.page) {
|
if (!changeLog.page) {
|
||||||
@@ -505,7 +507,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
|||||||
tab.editingSettings.usePowerUserMode = true;
|
tab.editingSettings.usePowerUserMode = true;
|
||||||
tab.core.settings.usePowerUserMode = true;
|
tab.core.settings.usePowerUserMode = true;
|
||||||
|
|
||||||
tab.requestReload();
|
await tab.requestReload();
|
||||||
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
|
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
|
||||||
expect(configureAnchor).toBeTypeOf("function");
|
expect(configureAnchor).toBeTypeOf("function");
|
||||||
let acceptExternalSetting: (() => void) | undefined;
|
let acceptExternalSetting: (() => void) | undefined;
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
type PageFunctions,
|
type PageFunctions,
|
||||||
type UpdateFunction,
|
type UpdateFunction,
|
||||||
} from "./SettingPane.ts";
|
} from "./SettingPane.ts";
|
||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
|
||||||
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
||||||
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
|
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
|
||||||
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
|
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||||
@@ -100,6 +99,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
}
|
}
|
||||||
selectedScreen = "";
|
selectedScreen = "";
|
||||||
|
|
||||||
|
private localConfigurationPassphrase = "";
|
||||||
|
private localSettingsReadVersion = 0;
|
||||||
|
|
||||||
_editingSettings?: AllSettings;
|
_editingSettings?: AllSettings;
|
||||||
// Buffered Settings for editing
|
// Buffered Settings for editing
|
||||||
get editingSettings(): AllSettings {
|
get editingSettings(): AllSettings {
|
||||||
@@ -162,8 +164,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
|
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
|
||||||
if (key == "deviceAndVaultName") {
|
if (key == "deviceAndVaultName") {
|
||||||
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
|
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
|
||||||
this.services.setting.saveDeviceAndVaultName();
|
await this.services.setting.saveDeviceAndVaultName();
|
||||||
return await Promise.resolve();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -225,6 +226,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
appliedConfigurationEncryption.configPassphrase
|
appliedConfigurationEncryption.configPassphrase
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (appliedConfigurationEncryption.configPassphraseStore === "LOCALSTORAGE") {
|
||||||
|
this.localSettingsReadVersion++;
|
||||||
|
this.localConfigurationPassphrase = appliedConfigurationEncryption.configPassphrase;
|
||||||
|
}
|
||||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
|
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
|
||||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
|
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
|
||||||
}
|
}
|
||||||
@@ -285,9 +290,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readLocalSettings(): Promise<boolean> {
|
||||||
|
const version = ++this.localSettingsReadVersion;
|
||||||
|
const passphrase = await this.services.setting.deviceValues.getValue("ls-setting-passphrase", "");
|
||||||
|
if (version !== this.localSettingsReadVersion) return false;
|
||||||
|
this.localConfigurationPassphrase = passphrase;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings(): Promise<void> {
|
||||||
|
if (await this.readLocalSettings()) this.reloadAllSettings(true);
|
||||||
|
}
|
||||||
|
|
||||||
reloadAllLocalSettings() {
|
reloadAllLocalSettings() {
|
||||||
const ret = { ...OnDialogSettingsDefault };
|
const ret = { ...OnDialogSettingsDefault };
|
||||||
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || "";
|
ret.configPassphrase = this.localConfigurationPassphrase;
|
||||||
ret.preset = "";
|
ret.preset = "";
|
||||||
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
|
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
|
||||||
return ret;
|
return ret;
|
||||||
@@ -402,7 +419,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
Setting.env = this;
|
Setting.env = this;
|
||||||
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
|
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
|
||||||
this.requestReload();
|
void this.requestReload().catch((error: unknown) => Logger(error, LOG_LEVEL_NOTICE));
|
||||||
});
|
});
|
||||||
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
|
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
|
||||||
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
|
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
|
||||||
@@ -545,7 +562,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
requestReload() {
|
async requestReload(): Promise<void> {
|
||||||
|
if (!(await this.readLocalSettings())) return;
|
||||||
const nativeTabIsShown =
|
const nativeTabIsShown =
|
||||||
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
|
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
|
||||||
if (this.isShown || nativeTabIsShown) {
|
if (this.isShown || nativeTabIsShown) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||||
|
|
||||||
const settingsInitialisationMocks = vi.hoisted(() => ({
|
const settingsInitialisationMocks = vi.hoisted(() => ({
|
||||||
applySettingsWithInitialisationChoice: vi.fn(),
|
applySettingsWithInitialisationChoice: vi.fn(),
|
||||||
@@ -76,8 +76,6 @@ beforeEach(() => {
|
|||||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
||||||
loggerMocks.Logger.mockClear();
|
loggerMocks.Logger.mockClear();
|
||||||
vi.mocked($msg).mockClear();
|
vi.mocked($msg).mockClear();
|
||||||
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
|
|
||||||
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||||
@@ -280,6 +278,7 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
|||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
setting: {
|
setting: {
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
saveSettingData,
|
saveSettingData,
|
||||||
getDeviceAndVaultName: vi.fn(() => ""),
|
getDeviceAndVaultName: vi.fn(() => ""),
|
||||||
suspendAllSync,
|
suspendAllSync,
|
||||||
@@ -665,6 +664,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
|||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
setting: {
|
setting: {
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
saveSettingData,
|
saveSettingData,
|
||||||
getDeviceAndVaultName: vi.fn(() => ""),
|
getDeviceAndVaultName: vi.fn(() => ""),
|
||||||
},
|
},
|
||||||
@@ -692,6 +692,54 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
|||||||
return { tab, saveSettingData };
|
return { tab, saveSettingData };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("awaits the stored passphrase before initialising the editing buffer", async () => {
|
||||||
|
const { tab } = createSettingsTab();
|
||||||
|
tab.resetEditingSettings();
|
||||||
|
let resolveRead!: (value: string) => void;
|
||||||
|
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
resolveRead = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const loading = tab.loadSettings();
|
||||||
|
expect(tab.initialSettings).toBeUndefined();
|
||||||
|
resolveRead("stored passphrase");
|
||||||
|
await loading;
|
||||||
|
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||||
|
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the loaded settings when device storage cannot be read", async () => {
|
||||||
|
const { tab } = createSettingsTab();
|
||||||
|
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "stored passphrase");
|
||||||
|
await tab.loadSettings();
|
||||||
|
const failure = new Error("device storage unavailable");
|
||||||
|
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockRejectedValueOnce(failure);
|
||||||
|
await expect(tab.requestReload()).rejects.toBe(failure);
|
||||||
|
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||||
|
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a delayed read overwrite a successfully saved passphrase", async () => {
|
||||||
|
const { tab } = createSettingsTab();
|
||||||
|
await tab.loadSettings();
|
||||||
|
let resolveRead!: (value: string) => void;
|
||||||
|
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
resolveRead = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const loading = tab.loadSettings();
|
||||||
|
Object.assign(tab.services.setting, { changeConfigurationEncryption: vi.fn(async () => undefined) });
|
||||||
|
tab.editingSettings.configPassphraseStore = "LOCALSTORAGE";
|
||||||
|
tab.editingSettings.configPassphrase = "new passphrase";
|
||||||
|
await tab.saveSettings(["configPassphraseStore", "configPassphrase"]);
|
||||||
|
resolveRead("old passphrase");
|
||||||
|
await loading;
|
||||||
|
tab.reloadAllSettings();
|
||||||
|
expect(tab.initialSettings!.configPassphrase).toBe("new passphrase");
|
||||||
|
});
|
||||||
|
|
||||||
it("loads the imperative fallback without a SettingPage runtime export", () => {
|
it("loads the imperative fallback without a SettingPage runtime export", () => {
|
||||||
const { tab } = createSettingsTab();
|
const { tab } = createSettingsTab();
|
||||||
|
|
||||||
@@ -779,7 +827,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
|||||||
it("saves a custom configuration passphrase edit through the setting service", async () => {
|
it("saves a custom configuration passphrase edit through the setting service", async () => {
|
||||||
const { tab } = createSettingsTab();
|
const { tab } = createSettingsTab();
|
||||||
const changeConfigurationEncryption = vi.fn(async () => {
|
const changeConfigurationEncryption = vi.fn(async () => {
|
||||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase");
|
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "new custom passphrase");
|
||||||
});
|
});
|
||||||
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
||||||
const store = "LOCALSTORAGE";
|
const store = "LOCALSTORAGE";
|
||||||
@@ -787,13 +835,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
|||||||
tab.initialSettings!.configPassphraseStore = store;
|
tab.initialSettings!.configPassphraseStore = store;
|
||||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||||
|
|
||||||
await tab.saveSettings(["configPassphrase"]);
|
await tab.saveSettings(["configPassphrase"]);
|
||||||
|
|
||||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
|
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
|
||||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
expect(setValue).toHaveBeenCalledOnce();
|
||||||
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
|
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -811,7 +859,6 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
|||||||
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
|
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
|
||||||
tab.initialSettings!.configPassphrase = "previous passphrase";
|
tab.initialSettings!.configPassphrase = "previous passphrase";
|
||||||
tab.editingSettings.configPassphrase = "passphrase A";
|
tab.editingSettings.configPassphrase = "passphrase A";
|
||||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
|
|
||||||
|
|
||||||
const apply = tab.saveSettings(["configPassphrase"]);
|
const apply = tab.saveSettings(["configPassphrase"]);
|
||||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
|
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
|
||||||
@@ -840,13 +887,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
|||||||
tab.editingSettings.configPassphraseStore = editedStore;
|
tab.editingSettings.configPassphraseStore = editedStore;
|
||||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||||
|
|
||||||
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
|
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
|
||||||
|
|
||||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
|
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
|
||||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
expect(setValue).not.toHaveBeenCalled();
|
||||||
expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
|
expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
|
||||||
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
|
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
|
||||||
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
|
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
@@ -37,11 +38,6 @@ import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/
|
|||||||
import { SetupManager, UserMode } from "./SetupManager";
|
import { SetupManager, UserMode } from "./SetupManager";
|
||||||
|
|
||||||
class TestSettingService extends SettingService<ServiceContext> {
|
class TestSettingService extends SettingService<ServiceContext> {
|
||||||
protected setItem(_key: string, _value: string): void {}
|
|
||||||
protected getItem(_key: string): string {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
protected deleteItem(_key: string): void {}
|
|
||||||
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
|
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
@@ -65,6 +61,7 @@ function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
|
|||||||
|
|
||||||
function createSetupManager() {
|
function createSetupManager() {
|
||||||
const setting = new TestSettingService(new ServiceContext(), {
|
const setting = new TestSettingService(new ServiceContext(), {
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
APIService: {
|
APIService: {
|
||||||
getSystemVaultName: vi.fn(() => "vault"),
|
getSystemVaultName: vi.fn(() => "vault"),
|
||||||
getAppID: vi.fn(() => "app"),
|
getAppID: vi.fn(() => "app"),
|
||||||
|
|||||||
@@ -52,26 +52,48 @@
|
|||||||
const context = getDialogContext();
|
const context = getDialogContext();
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let connectionPathResetNotice = $state(false);
|
let connectionPathResetNotice = $state(false);
|
||||||
|
let peerNameReady = $state(false);
|
||||||
|
let mounted = false;
|
||||||
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
|
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
|
||||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
|
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
|
||||||
|
|
||||||
const { setResult, getInitialData }: Props = $props();
|
const { setResult, getInitialData }: Props = $props();
|
||||||
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
|
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
const initialData = getInitialData?.();
|
const initialData = getInitialData?.();
|
||||||
connectionProbe = initialData?.connectionProbe;
|
connectionProbe = initialData?.connectionProbe;
|
||||||
const initialSettings = initialData?.settings;
|
const initialSettings = initialData?.settings;
|
||||||
if (initialSettings) {
|
if (initialSettings) {
|
||||||
syncSetting = pickP2PSyncSettings(initialSettings);
|
syncSetting = pickP2PSyncSettings(initialSettings);
|
||||||
}
|
}
|
||||||
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim();
|
const savedPeerName = initialSettings?.P2P_DevicePeerName;
|
||||||
|
const initialPeerName = typeof savedPeerName === "string" ? savedPeerName.trim() : "";
|
||||||
if (initialPeerName !== "") {
|
if (initialPeerName !== "") {
|
||||||
return;
|
peerNameReady = true;
|
||||||
}
|
return () => {
|
||||||
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME);
|
mounted = false;
|
||||||
if (cachedPeerName) {
|
};
|
||||||
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
|
|
||||||
}
|
}
|
||||||
|
void context.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME).then(
|
||||||
|
(cachedPeerName) => {
|
||||||
|
if (mounted) {
|
||||||
|
if (typeof cachedPeerName === "string" && cachedPeerName) {
|
||||||
|
syncSetting.P2P_DevicePeerName = cachedPeerName;
|
||||||
|
}
|
||||||
|
peerNameReady = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(loadError: unknown) => {
|
||||||
|
if (mounted) {
|
||||||
|
error = loadError instanceof Error ? loadError.message : String(loadError);
|
||||||
|
peerNameReady = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
function generateSetting() {
|
function generateSetting() {
|
||||||
const connSetting: P2PSyncSetting = {
|
const connSetting: P2PSyncSetting = {
|
||||||
@@ -193,6 +215,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndCommit() {
|
async function checkAndCommit() {
|
||||||
|
if (!peerNameReady) return;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
error = (await checkConnection()) || "";
|
error = (await checkConnection()) || "";
|
||||||
@@ -207,6 +230,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function commit() {
|
function commit() {
|
||||||
|
if (!peerNameReady) return;
|
||||||
error = validateManagedTurnSettings(syncSetting) ?? "";
|
error = validateManagedTurnSettings(syncSetting) ?? "";
|
||||||
if (error) return;
|
if (error) return;
|
||||||
const setting = pickP2PSyncSettings(generateSetting());
|
const setting = pickP2PSyncSettings(generateSetting());
|
||||||
@@ -217,6 +241,7 @@
|
|||||||
}
|
}
|
||||||
const canProceed = $derived.by(() => {
|
const canProceed = $derived.by(() => {
|
||||||
return (
|
return (
|
||||||
|
peerNameReady &&
|
||||||
syncSetting.P2P_relays.trim() !== "" &&
|
syncSetting.P2P_relays.trim() !== "" &&
|
||||||
syncSetting.P2P_roomID.trim() !== "" &&
|
syncSetting.P2P_roomID.trim() !== "" &&
|
||||||
syncSetting.P2P_passphrase.trim() !== "" &&
|
syncSetting.P2P_passphrase.trim() !== "" &&
|
||||||
@@ -291,6 +316,7 @@
|
|||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
|
disabled={!peerNameReady}
|
||||||
bind:value={syncSetting.P2P_DevicePeerName}
|
bind:value={syncSetting.P2P_DevicePeerName}
|
||||||
/>
|
/>
|
||||||
</InputRow>
|
</InputRow>
|
||||||
@@ -385,7 +411,11 @@
|
|||||||
disabled={!canProceed}
|
disabled={!canProceed}
|
||||||
commit={() => checkAndCommit()}
|
commit={() => checkAndCommit()}
|
||||||
/>
|
/>
|
||||||
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} />
|
<Decision
|
||||||
|
title={translateMessage("Continue anyway")}
|
||||||
|
disabled={!peerNameReady}
|
||||||
|
commit={() => commit()}
|
||||||
|
/>
|
||||||
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
|
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
|
||||||
</UserDecisions>
|
</UserDecisions>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||||
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
|
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||||
import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||||
import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub";
|
import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub";
|
||||||
import type ObsidianLiveSyncPlugin from "@/main";
|
import type ObsidianLiveSyncPlugin from "@/main";
|
||||||
@@ -11,7 +13,6 @@ import {
|
|||||||
ObsidianTweakValueService,
|
ObsidianTweakValueService,
|
||||||
ObsidianTestService,
|
ObsidianTestService,
|
||||||
ObsidianDatabaseEventService,
|
ObsidianDatabaseEventService,
|
||||||
ObsidianConfigService,
|
|
||||||
ObsidianKeyValueDBService,
|
ObsidianKeyValueDBService,
|
||||||
ObsidianControlService,
|
ObsidianControlService,
|
||||||
} from "./ObsidianServices";
|
} from "./ObsidianServices";
|
||||||
@@ -46,6 +47,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
|||||||
const setting = new ObsidianSettingService(context, {
|
const setting = new ObsidianSettingService(context, {
|
||||||
APIService: API,
|
APIService: API,
|
||||||
onDisplayLanguageChanged: setLang,
|
onDisplayLanguageChanged: setLang,
|
||||||
|
deviceValues: createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||||
});
|
});
|
||||||
const appLifecycle = new ObsidianAppLifecycleService(context, {
|
const appLifecycle = new ObsidianAppLifecycleService(context, {
|
||||||
settingService: setting,
|
settingService: setting,
|
||||||
@@ -85,10 +87,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
|||||||
databaseEvents: databaseEvents,
|
databaseEvents: databaseEvents,
|
||||||
vault: vault,
|
vault: vault,
|
||||||
});
|
});
|
||||||
const config = new ObsidianConfigService(context, {
|
const config = setting.config;
|
||||||
settingService: setting,
|
|
||||||
APIService: API,
|
|
||||||
});
|
|
||||||
const replicator = new ObsidianReplicatorService(context, {
|
const replicator = new ObsidianReplicatorService(context, {
|
||||||
settingService: setting,
|
settingService: setting,
|
||||||
appLifecycleService: appLifecycle,
|
appLifecycleService: appLifecycle,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/
|
|||||||
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
|
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
|
||||||
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
|
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
|
||||||
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
|
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
|
||||||
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
|
|
||||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||||
@@ -58,10 +57,7 @@ export class ObsidianReplicatorService extends InjectableReplicatorService<Obsid
|
|||||||
super(context, withSleepPreference(dependencies));
|
super(context, withSleepPreference(dependencies));
|
||||||
}
|
}
|
||||||
|
|
||||||
async runBoundedLocalApplicationActivity<T>(
|
async runBoundedLocalApplicationActivity<T>(task: () => T | PromiseLike<T>, options?: ActivityOptions): Promise<T> {
|
||||||
task: () => T | PromiseLike<T>,
|
|
||||||
options?: ActivityOptions
|
|
||||||
): Promise<T> {
|
|
||||||
this.boundedLocalApplicationActivityCount.value++;
|
this.boundedLocalApplicationActivityCount.value++;
|
||||||
try {
|
try {
|
||||||
return this.dependencies.activityRunner
|
return this.dependencies.activityRunner
|
||||||
@@ -84,7 +80,6 @@ export class ObsidianConflictService extends InjectableConflictService<ObsidianS
|
|||||||
export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {}
|
export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {}
|
||||||
// InjectableTestService
|
// InjectableTestService
|
||||||
export class ObsidianTestService extends InjectableTestService<ObsidianServiceContext> {}
|
export class ObsidianTestService extends InjectableTestService<ObsidianServiceContext> {}
|
||||||
export class ObsidianConfigService extends ConfigServiceBrowserCompat<ObsidianServiceContext> {}
|
|
||||||
|
|
||||||
export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {}
|
export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
|
||||||
import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import {
|
import {
|
||||||
EVENT_REQUEST_RELOAD_SETTING_TAB,
|
EVENT_REQUEST_RELOAD_SETTING_TAB,
|
||||||
@@ -27,19 +26,6 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
|
|||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
protected setItem(key: string, value: string) {
|
|
||||||
// TODO: Implement nativeLocalStorage.
|
|
||||||
return compatGlobal.localStorage.setItem(key, value);
|
|
||||||
}
|
|
||||||
protected getItem(key: string): string {
|
|
||||||
// TODO: Implement nativeLocalStorage.
|
|
||||||
return compatGlobal.localStorage.getItem(key) ?? "";
|
|
||||||
}
|
|
||||||
protected deleteItem(key: string): void {
|
|
||||||
// TODO: Implement nativeLocalStorage.
|
|
||||||
compatGlobal.localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
|
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||||
return await this.context.liveSyncPlugin.saveData(data);
|
return await this.context.liveSyncPlugin.saveData(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
|
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||||
|
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||||
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
|
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
|
||||||
|
import { ObsidianSettingService } from "./ObsidianSettingService.ts";
|
||||||
|
|
||||||
|
function createSettingService(
|
||||||
|
vaultName: string,
|
||||||
|
deviceValues: MemoryValueStore
|
||||||
|
): ObsidianSettingService<ObsidianServiceContext> {
|
||||||
|
const context = createServiceContext() as unknown as ObsidianServiceContext;
|
||||||
|
const APIService = {
|
||||||
|
addLog: () => undefined,
|
||||||
|
getSystemVaultName: () => vaultName,
|
||||||
|
} as unknown as IAPIService;
|
||||||
|
return new ObsidianSettingService(context, { APIService, deviceValues });
|
||||||
|
}
|
||||||
|
|
||||||
describe("normaliseObsidianSettingsData", () => {
|
describe("normaliseObsidianSettingsData", () => {
|
||||||
it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => {
|
it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => {
|
||||||
@@ -12,4 +29,26 @@ describe("normaliseObsidianSettingsData", () => {
|
|||||||
|
|
||||||
expect(normaliseObsidianSettingsData(settings)).toBe(settings);
|
expect(normaliseObsidianSettingsData(settings)).toBe(settings);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps injected device values independent from each Vault-scoped configuration store", async () => {
|
||||||
|
const firstValues = new MemoryValueStore();
|
||||||
|
const secondValues = new MemoryValueStore();
|
||||||
|
const firstSetting = createSettingService("first-vault", firstValues);
|
||||||
|
const secondSetting = createSettingService("second-vault", secondValues);
|
||||||
|
|
||||||
|
await firstSetting.deviceValues.setValue("shared-key", "device value");
|
||||||
|
await firstSetting.config.setValue("shared-key", "first config");
|
||||||
|
await secondSetting.config.setValue("shared-key", "second config");
|
||||||
|
|
||||||
|
expect(firstSetting.deviceValues).toBe(firstValues);
|
||||||
|
expect(secondSetting.deviceValues).toBe(secondValues);
|
||||||
|
expect(firstSetting.deviceValues).not.toBe(secondSetting.deviceValues);
|
||||||
|
expect(firstSetting.config).not.toBe(firstValues);
|
||||||
|
expect(firstSetting.config).not.toBe(secondSetting.config);
|
||||||
|
await expect(firstSetting.deviceValues.getValue("shared-key")).resolves.toBe("device value");
|
||||||
|
await expect(firstSetting.config.getValue("shared-key")).resolves.toBe("first config");
|
||||||
|
await expect(secondSetting.config.getValue("shared-key")).resolves.toBe("second config");
|
||||||
|
await expect(firstValues.getValue("first-vault-shared-key")).resolves.toBe("first config");
|
||||||
|
await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||||
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||||
@@ -8,7 +8,7 @@ import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy
|
|||||||
import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = {
|
export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = {
|
||||||
appLifecycle: AppLifecycleService<T>;
|
appLifecycle: AppLifecycleService<T>;
|
||||||
config: ConfigService<T>;
|
config: IConfigService;
|
||||||
replicator: ReplicatorService<T>;
|
replicator: ReplicatorService<T>;
|
||||||
APIService: IAPIService;
|
APIService: IAPIService;
|
||||||
control: IControlService;
|
control: IControlService;
|
||||||
|
|||||||
@@ -44,17 +44,17 @@ export class CompatibilityReviewController {
|
|||||||
return this._initialised;
|
return this._initialised;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readAcknowledgedVersion(): string | null {
|
private async readAcknowledgedVersion(): Promise<string | null> {
|
||||||
const setting = this.core.services.setting;
|
const setting = this.core.services.setting;
|
||||||
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
|
const currentMarker = await this.core.services.config.getValue(DATABASE_COMPATIBILITY_VERSION_KEY, "");
|
||||||
if (currentMarker) return currentMarker;
|
if (currentMarker) return currentMarker;
|
||||||
|
|
||||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||||
const legacyMarker = setting.getDeviceLocalConfig(legacyKey);
|
const legacyMarker = await setting.deviceValues.getValue(legacyKey, "");
|
||||||
if (!legacyMarker) return null;
|
if (!legacyMarker) return null;
|
||||||
|
|
||||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||||
setting.deleteDeviceLocalConfig(legacyKey);
|
await setting.deviceValues.delete(legacyKey);
|
||||||
return legacyMarker;
|
return legacyMarker;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export class CompatibilityReviewController {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const acknowledgedVersion = this.readAcknowledgedVersion();
|
const acknowledgedVersion = await this.readAcknowledgedVersion();
|
||||||
const evaluation = evaluateCompatibilityPause({
|
const evaluation = evaluateCompatibilityPause({
|
||||||
acknowledgedVersion,
|
acknowledgedVersion,
|
||||||
currentVersion: this.currentVersion,
|
currentVersion: this.currentVersion,
|
||||||
@@ -85,7 +85,7 @@ export class CompatibilityReviewController {
|
|||||||
|
|
||||||
this.pause = evaluation.pause;
|
this.pause = evaluation.pause;
|
||||||
if (evaluation.initialiseAcknowledgedVersion) {
|
if (evaluation.initialiseAcknowledgedVersion) {
|
||||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||||
this._initialised = true;
|
this._initialised = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -115,9 +115,9 @@ export class CompatibilityReviewController {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||||
setting.deleteDeviceLocalConfig(legacyKey);
|
await setting.deviceValues.delete(legacyKey);
|
||||||
this.pause = undefined;
|
this.pause = undefined;
|
||||||
this.ui.clearReminder();
|
this.ui.clearReminder();
|
||||||
await this.core.services.control.applySettings();
|
await this.core.services.control.applySettings();
|
||||||
|
|||||||
@@ -33,13 +33,32 @@ function createFixture(
|
|||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const local = new Map<string, string>();
|
const local = new Map<string, string>();
|
||||||
|
const deviceValues = new Map<string, string>();
|
||||||
if (options.marker !== undefined && options.marker !== null) {
|
if (options.marker !== undefined && options.marker !== null) {
|
||||||
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
|
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
|
||||||
}
|
}
|
||||||
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
|
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
|
||||||
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
||||||
local.set(legacyKey, options.legacyMarker);
|
deviceValues.set(legacyKey, options.legacyMarker);
|
||||||
}
|
}
|
||||||
|
const config = {
|
||||||
|
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
|
||||||
|
setValue: vi.fn(async (key: string, value: string) => {
|
||||||
|
local.set(key, value);
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async (key: string) => {
|
||||||
|
local.delete(key);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const deviceValueStore = {
|
||||||
|
getValue: vi.fn(async (key: string, defaultValue = "") => deviceValues.get(key) ?? defaultValue),
|
||||||
|
setValue: vi.fn(async (key: string, value: string) => {
|
||||||
|
deviceValues.set(key, value);
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async (key: string) => {
|
||||||
|
deviceValues.delete(key);
|
||||||
|
}),
|
||||||
|
};
|
||||||
const settings = {
|
const settings = {
|
||||||
versionUpFlash: options.versionUpFlash ?? "",
|
versionUpFlash: options.versionUpFlash ?? "",
|
||||||
isConfigured: options.isConfigured ?? true,
|
isConfigured: options.isConfigured ?? true,
|
||||||
@@ -49,15 +68,13 @@ function createFixture(
|
|||||||
const setting = {
|
const setting = {
|
||||||
currentSettings: () => settings,
|
currentSettings: () => settings,
|
||||||
getSettingsMigrationState: () => migrationState(options.migration),
|
getSettingsMigrationState: () => migrationState(options.migration),
|
||||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
deviceValues: deviceValueStore,
|
||||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
|
||||||
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
|
|
||||||
deleteDeviceLocalConfig: (key: string) => local.delete(key),
|
|
||||||
saveSettingData,
|
saveSettingData,
|
||||||
};
|
};
|
||||||
const core = {
|
const core = {
|
||||||
services: {
|
services: {
|
||||||
setting,
|
setting,
|
||||||
|
config,
|
||||||
vault: { getVaultName: () => "Test Vault" },
|
vault: { getVaultName: () => "Test Vault" },
|
||||||
control: { applySettings },
|
control: { applySettings },
|
||||||
},
|
},
|
||||||
@@ -69,7 +86,18 @@ function createFixture(
|
|||||||
clearReminder: vi.fn(),
|
clearReminder: vi.fn(),
|
||||||
};
|
};
|
||||||
const controller = new CompatibilityReviewController(core, ui, 12);
|
const controller = new CompatibilityReviewController(core, ui, 12);
|
||||||
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings };
|
return {
|
||||||
|
controller,
|
||||||
|
ui,
|
||||||
|
local,
|
||||||
|
deviceValues,
|
||||||
|
config,
|
||||||
|
deviceValueStore,
|
||||||
|
legacyKey,
|
||||||
|
settings,
|
||||||
|
saveSettingData,
|
||||||
|
applySettings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("compatibility review controller", () => {
|
describe("compatibility review controller", () => {
|
||||||
@@ -196,10 +224,96 @@ describe("compatibility review controller", () => {
|
|||||||
await fixture.controller.initialise();
|
await fixture.controller.initialise();
|
||||||
|
|
||||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||||
expect(fixture.local.has(fixture.legacyKey)).toBe(false);
|
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||||
expect(fixture.controller.pendingPause).toBeDefined();
|
expect(fixture.controller.pendingPause).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("saves the scoped marker before deleting the legacy device value", async () => {
|
||||||
|
const fixture = createFixture({ legacyMarker: "11" });
|
||||||
|
let finishSave!: () => void;
|
||||||
|
const saving = new Promise<void>((resolve) => {
|
||||||
|
finishSave = resolve;
|
||||||
|
});
|
||||||
|
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||||
|
await saving;
|
||||||
|
fixture.local.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialising = fixture.controller.initialise();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||||
|
expect(fixture.deviceValueStore.delete).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.controller.initialised).toBe(false);
|
||||||
|
|
||||||
|
finishSave();
|
||||||
|
await initialising;
|
||||||
|
|
||||||
|
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||||
|
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||||
|
expect(fixture.controller.initialised).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a failed marker read as an absent value", async () => {
|
||||||
|
const fixture = createFixture({ legacyMarker: "11" });
|
||||||
|
fixture.config.getValue.mockRejectedValueOnce(new Error("marker read failed"));
|
||||||
|
|
||||||
|
await expect(fixture.controller.initialise()).rejects.toThrow("marker read failed");
|
||||||
|
|
||||||
|
expect(fixture.deviceValueStore.getValue).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||||
|
expect(fixture.controller.initialised).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the compatibility pause blocked until its async marker write succeeds", async () => {
|
||||||
|
const fixture = createFixture({ marker: "11" });
|
||||||
|
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||||
|
await fixture.controller.initialise();
|
||||||
|
let finishSave!: () => void;
|
||||||
|
const saving = new Promise<void>((resolve) => {
|
||||||
|
finishSave = resolve;
|
||||||
|
});
|
||||||
|
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||||
|
await saving;
|
||||||
|
fixture.local.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewing = fixture.controller.openReview();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fixture.controller.pendingPause).toBeDefined();
|
||||||
|
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||||
|
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
finishSave();
|
||||||
|
await reviewing;
|
||||||
|
|
||||||
|
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||||
|
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||||
|
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a storage-failed acknowledgement blocked and retryable", async () => {
|
||||||
|
const fixture = createFixture({ marker: "11" });
|
||||||
|
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||||
|
await fixture.controller.initialise();
|
||||||
|
fixture.config.setValue.mockRejectedValueOnce(new Error("marker write failed"));
|
||||||
|
|
||||||
|
await expect(fixture.controller.openReview()).rejects.toThrow("marker write failed");
|
||||||
|
|
||||||
|
expect(fixture.controller.pendingPause).toBeDefined();
|
||||||
|
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||||
|
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await fixture.controller.openReview();
|
||||||
|
|
||||||
|
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||||
|
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||||
|
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("restores the runtime gate if persisting an acknowledgement fails", async () => {
|
it("restores the runtime gate if persisting an acknowledgement fails", async () => {
|
||||||
const fixture = createFixture({ marker: "11" });
|
const fixture = createFixture({ marker: "11" });
|
||||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||||
|
|||||||
@@ -63,28 +63,27 @@ function buildSimpleFetchResult(stage1: string, stage2?: string) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
async function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
||||||
host.services.setting.setSmallConfig(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
await host.services.setting.config.setValue(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
async function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||||
const saved = host.services.setting.getSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
const saved = await host.services.setting.config.getValue(SIMPLE_FETCH_MODE_KEY, "");
|
||||||
if (!saved) return undefined;
|
if (!saved) return undefined;
|
||||||
|
let remembered: ReturnType<typeof buildSimpleFetchResult> = undefined;
|
||||||
try {
|
try {
|
||||||
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
|
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
|
||||||
if (stage1) {
|
if (stage1) remembered = buildSimpleFetchResult(stage1, stage2);
|
||||||
const remembered = buildSimpleFetchResult(stage1, stage2);
|
|
||||||
if (remembered) return remembered;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Clear below; the saved choice is optional and can be rebuilt by asking again.
|
// Clear below; the saved choice is optional and can be rebuilt by asking again.
|
||||||
}
|
}
|
||||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
if (remembered) return remembered;
|
||||||
|
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
async function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmTranslatedChoice(
|
async function confirmTranslatedChoice(
|
||||||
@@ -104,7 +103,7 @@ async function confirmTranslatedChoice(
|
|||||||
export async function askSimpleFetchMode(
|
export async function askSimpleFetchMode(
|
||||||
host: NecessaryServices<"UI" | "setting", never>
|
host: NecessaryServices<"UI" | "setting", never>
|
||||||
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
|
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
|
||||||
const remembered = getRememberedSimpleFetchMode(host);
|
const remembered = await getRememberedSimpleFetchMode(host);
|
||||||
if (remembered) return remembered;
|
if (remembered) return remembered;
|
||||||
|
|
||||||
const msg = $msg(
|
const msg = $msg(
|
||||||
@@ -169,7 +168,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
|||||||
if (stage2 === STAGE2_ABORT) {
|
if (stage2 === STAGE2_ABORT) {
|
||||||
return "aborted";
|
return "aborted";
|
||||||
}
|
}
|
||||||
rememberSimpleFetchMode(host, stage1, stage2);
|
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||||
return buildSimpleFetchResult(stage1, stage2)!;
|
return buildSimpleFetchResult(stage1, stage2)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +197,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
|||||||
if (stage2 === STAGE2_ABORT) {
|
if (stage2 === STAGE2_ABORT) {
|
||||||
return "aborted";
|
return "aborted";
|
||||||
}
|
}
|
||||||
rememberSimpleFetchMode(host, stage1, stage2);
|
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||||
return buildSimpleFetchResult(stage1, stage2)!;
|
return buildSimpleFetchResult(stage1, stage2)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,18 +233,18 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
|||||||
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
|
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
|
||||||
LOG_LEVEL_NOTICE
|
LOG_LEVEL_NOTICE
|
||||||
);
|
);
|
||||||
clearRememberedSimpleFetchMode(host);
|
await clearRememberedSimpleFetchMode(host);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const result = await askSimpleFetchMode(host);
|
const result = await askSimpleFetchMode(host);
|
||||||
if (result === "cancelled") {
|
if (result === "cancelled") {
|
||||||
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
||||||
clearRememberedSimpleFetchMode(host);
|
await clearRememberedSimpleFetchMode(host);
|
||||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||||
}
|
}
|
||||||
if (result === "aborted") {
|
if (result === "aborted") {
|
||||||
log("Fetch exited by user.", LOG_LEVEL_NOTICE);
|
log("Fetch exited by user.", LOG_LEVEL_NOTICE);
|
||||||
clearRememberedSimpleFetchMode(host);
|
await clearRememberedSimpleFetchMode(host);
|
||||||
host.services.appLifecycle.performRestart();
|
host.services.appLifecycle.performRestart();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -256,7 +255,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
|||||||
const settings = host.services.setting.currentSettings();
|
const settings = host.services.setting.currentSettings();
|
||||||
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
|
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
|
||||||
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
|
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
|
||||||
clearRememberedSimpleFetchMode(host);
|
await clearRememberedSimpleFetchMode(host);
|
||||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +294,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
|||||||
}
|
}
|
||||||
await host.serviceModules.rebuilder.finishRebuild();
|
await host.serviceModules.rebuilder.finishRebuild();
|
||||||
await cleanupFlag();
|
await cleanupFlag();
|
||||||
clearRememberedSimpleFetchMode(host);
|
await clearRememberedSimpleFetchMode(host);
|
||||||
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
|
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,19 +10,26 @@ const KEEP_LOCAL = "Keep local files even if not on remote";
|
|||||||
function createHost(savedChoice?: string) {
|
function createHost(savedChoice?: string) {
|
||||||
const smallConfig = new Map<string, string>();
|
const smallConfig = new Map<string, string>();
|
||||||
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
|
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
|
||||||
|
const config = {
|
||||||
|
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||||
|
setValue: vi.fn(async (key: string, value: string) => {
|
||||||
|
smallConfig.set(key, value);
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async (key: string) => {
|
||||||
|
smallConfig.delete(key);
|
||||||
|
}),
|
||||||
|
};
|
||||||
const confirmWithMessage =
|
const confirmWithMessage =
|
||||||
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
|
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
|
||||||
const host = {
|
const host = {
|
||||||
services: {
|
services: {
|
||||||
UI: { confirm: { confirmWithMessage } },
|
UI: { confirm: { confirmWithMessage } },
|
||||||
setting: {
|
setting: {
|
||||||
getSmallConfig: (key: string) => smallConfig.get(key) ?? "",
|
config,
|
||||||
setSmallConfig: (key: string, value: string) => smallConfig.set(key, value),
|
|
||||||
deleteSmallConfig: (key: string) => smallConfig.delete(key),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as NecessaryServices<"UI" | "setting", never>;
|
} as unknown as NecessaryServices<"UI" | "setting", never>;
|
||||||
return { host, confirmWithMessage, smallConfig };
|
return { host, confirmWithMessage, smallConfig, config };
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => setLang("def"));
|
afterEach(() => setLang("def"));
|
||||||
@@ -50,6 +57,75 @@ describe("Fast Setup translated choices", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("waits for the saved choice before returning it to the fetch workflow", async () => {
|
||||||
|
const { host, confirmWithMessage, smallConfig, config } = createHost();
|
||||||
|
let markSaveStarted!: () => void;
|
||||||
|
const saveStarted = new Promise<void>((resolve) => {
|
||||||
|
markSaveStarted = resolve;
|
||||||
|
});
|
||||||
|
let finishSave!: () => void;
|
||||||
|
const save = new Promise<void>((resolve) => {
|
||||||
|
finishSave = resolve;
|
||||||
|
});
|
||||||
|
config.setValue.mockImplementation(async (key, value) => {
|
||||||
|
markSaveStarted();
|
||||||
|
await save;
|
||||||
|
smallConfig.set(key, value);
|
||||||
|
});
|
||||||
|
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
|
||||||
|
|
||||||
|
const asking = askSimpleFetchMode(host);
|
||||||
|
await saveStarted;
|
||||||
|
|
||||||
|
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||||
|
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
finishSave();
|
||||||
|
await expect(asking).resolves.toMatchObject({ mode: "remote-only" });
|
||||||
|
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
|
||||||
|
stage1: REMOTE_WINS,
|
||||||
|
stage2: KEEP_LOCAL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a stored-choice read failure instead of treating it as missing", async () => {
|
||||||
|
const { host, confirmWithMessage, config } = createHost();
|
||||||
|
config.getValue.mockRejectedValueOnce(new Error("choice read failed"));
|
||||||
|
|
||||||
|
await expect(askSimpleFetchMode(host)).rejects.toThrow("choice read failed");
|
||||||
|
|
||||||
|
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for an invalid optional choice to be deleted before asking again", async () => {
|
||||||
|
const { host, confirmWithMessage, smallConfig, config } = createHost("not-json");
|
||||||
|
let markDeleteStarted!: () => void;
|
||||||
|
const deleteStarted = new Promise<void>((resolve) => {
|
||||||
|
markDeleteStarted = resolve;
|
||||||
|
});
|
||||||
|
let finishDelete!: () => void;
|
||||||
|
const deletion = new Promise<void>((resolve) => {
|
||||||
|
finishDelete = resolve;
|
||||||
|
});
|
||||||
|
config.delete.mockImplementation(async (key) => {
|
||||||
|
markDeleteStarted();
|
||||||
|
await deletion;
|
||||||
|
smallConfig.delete(key);
|
||||||
|
});
|
||||||
|
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
|
||||||
|
|
||||||
|
const asking = askSimpleFetchMode(host);
|
||||||
|
await deleteStarted;
|
||||||
|
|
||||||
|
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||||
|
expect(smallConfig.has("simple-fetch-mode")).toBe(true);
|
||||||
|
|
||||||
|
finishDelete();
|
||||||
|
await expect(asking).resolves.toBe("cancelled");
|
||||||
|
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||||
|
expect(confirmWithMessage).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("restores choices saved by the previous English implementation while displaying Spanish", async () => {
|
it("restores choices saved by the previous English implementation while displaying Spanish", async () => {
|
||||||
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
|
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
|
||||||
const { host, confirmWithMessage, smallConfig } = createHost(savedChoice);
|
const { host, confirmWithMessage, smallConfig } = createHost(savedChoice);
|
||||||
|
|||||||
@@ -107,13 +107,15 @@ const createSettingServiceMock = () => {
|
|||||||
}),
|
}),
|
||||||
suspendAllSync: vi.fn(() => Promise.resolve()),
|
suspendAllSync: vi.fn(() => Promise.resolve()),
|
||||||
suspendExtraSync: vi.fn(() => Promise.resolve()),
|
suspendExtraSync: vi.fn(() => Promise.resolve()),
|
||||||
getSmallConfig: vi.fn((key: string) => smallConfig.get(key) ?? ""),
|
config: {
|
||||||
setSmallConfig: vi.fn((key: string, value: string) => {
|
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||||
smallConfig.set(key, value);
|
setValue: vi.fn(async (key: string, value: string) => {
|
||||||
}),
|
smallConfig.set(key, value);
|
||||||
deleteSmallConfig: vi.fn((key: string) => {
|
}),
|
||||||
smallConfig.delete(key);
|
delete: vi.fn(async (key: string) => {
|
||||||
}),
|
smallConfig.delete(key);
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -785,7 +787,7 @@ describe("Red Flag Feature", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
||||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -830,7 +832,7 @@ describe("Red Flag Feature", () => {
|
|||||||
});
|
});
|
||||||
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
|
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
|
||||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||||
expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode");
|
expect(host.mocks.setting.config.delete).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
|
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
|
||||||
@@ -892,7 +894,7 @@ describe("Red Flag Feature", () => {
|
|||||||
|
|
||||||
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
||||||
|
|
||||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false and cleanup when quick flow is cancelled", async () => {
|
it("should return false and cleanup when quick flow is cancelled", async () => {
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ export function useReviewHarness(
|
|||||||
await createFileContentHashBenchmarkOperations(activeWindow.performance),
|
await createFileContentHashBenchmarkOperations(activeWindow.performance),
|
||||||
activeWindow.performance
|
activeWindow.performance
|
||||||
),
|
),
|
||||||
readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
readContinuation: () => services.config.getValue(REVIEW_HARNESS_STATE_KEY, ""),
|
||||||
writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value),
|
writeContinuation: (value) => services.config.setValue(REVIEW_HARNESS_STATE_KEY, value),
|
||||||
deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
deleteContinuation: () => services.config.delete(REVIEW_HARNESS_STATE_KEY),
|
||||||
restart: () => services.appLifecycle.performRestart(),
|
restart: () => services.appLifecycle.performRestart(),
|
||||||
reportError: (error) => services.API.addLog(error, LOG_LEVEL_NOTICE),
|
reportError: (error) => services.API.addLog(error, LOG_LEVEL_NOTICE),
|
||||||
copyText: async (value) => {
|
copyText: async (value) => {
|
||||||
@@ -94,14 +94,14 @@ export function useReviewHarness(
|
|||||||
let registered = false;
|
let registered = false;
|
||||||
let openAfterLayout = false;
|
let openAfterLayout = false;
|
||||||
|
|
||||||
services.appLifecycle.onSettingLoaded.addHandler(() => {
|
services.appLifecycle.onSettingLoaded.addHandler(async () => {
|
||||||
if (!continuationConsumed) {
|
if (!continuationConsumed) {
|
||||||
|
await controller.consumeContinuation();
|
||||||
continuationConsumed = true;
|
continuationConsumed = true;
|
||||||
controller.consumeContinuation();
|
|
||||||
}
|
}
|
||||||
const snapshot = controller.snapshot();
|
const snapshot = controller.snapshot();
|
||||||
openAfterLayout = snapshot.resumedRequestId !== null || snapshot.continuationError !== null;
|
openAfterLayout = snapshot.resumedRequestId !== null || snapshot.continuationError !== null;
|
||||||
if (!services.setting.currentSettings().enableDebugTools || registered) return Promise.resolve(true);
|
if (!services.setting.currentSettings().enableDebugTools || registered) return true;
|
||||||
|
|
||||||
registered = true;
|
registered = true;
|
||||||
services.API.registerWindow(
|
services.API.registerWindow(
|
||||||
@@ -115,7 +115,7 @@ export function useReviewHarness(
|
|||||||
void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS);
|
void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return Promise.resolve(true);
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
services.appLifecycle.onLayoutReady.addHandler(() => {
|
services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
|||||||
const layoutReadyHandlers: Array<() => Promise<boolean>> = [];
|
const layoutReadyHandlers: Array<() => Promise<boolean>> = [];
|
||||||
const local = new Map<string, string>();
|
const local = new Map<string, string>();
|
||||||
if (options.continuation) local.set(REVIEW_HARNESS_STATE_KEY, options.continuation);
|
if (options.continuation) local.set(REVIEW_HARNESS_STATE_KEY, options.continuation);
|
||||||
|
const config = {
|
||||||
|
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
|
||||||
|
setValue: vi.fn(async (key: string, value: string) => {
|
||||||
|
local.set(key, value);
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async (key: string) => {
|
||||||
|
local.delete(key);
|
||||||
|
}),
|
||||||
|
};
|
||||||
const settings = {
|
const settings = {
|
||||||
enableDebugTools: options.enableDebugTools ?? true,
|
enableDebugTools: options.enableDebugTools ?? true,
|
||||||
liveSync: false,
|
liveSync: false,
|
||||||
@@ -63,10 +72,8 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
|||||||
requiresSyncReview: true,
|
requiresSyncReview: true,
|
||||||
reviewReasons: [],
|
reviewReasons: [],
|
||||||
}),
|
}),
|
||||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
|
||||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
|
||||||
deleteSmallConfig: (key: string) => local.delete(key),
|
|
||||||
},
|
},
|
||||||
|
config,
|
||||||
appLifecycle: {
|
appLifecycle: {
|
||||||
onSettingLoaded: {
|
onSettingLoaded: {
|
||||||
addHandler: vi.fn((handler: () => Promise<boolean>) => settingLoadedHandlers.push(handler)),
|
addHandler: vi.fn((handler: () => Promise<boolean>) => settingLoadedHandlers.push(handler)),
|
||||||
@@ -106,6 +113,7 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
|||||||
api,
|
api,
|
||||||
settings,
|
settings,
|
||||||
local,
|
local,
|
||||||
|
config,
|
||||||
settingLoadedHandlers,
|
settingLoadedHandlers,
|
||||||
layoutReadyHandlers,
|
layoutReadyHandlers,
|
||||||
compatibilityReview,
|
compatibilityReview,
|
||||||
@@ -144,6 +152,74 @@ describe("Review Harness composition", () => {
|
|||||||
expect(fixture.api.showWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS);
|
expect(fixture.api.showWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("holds the setting-loaded lifecycle until continuation deletion has completed", async () => {
|
||||||
|
const requestedAt = "2026-07-18T11:59:00.000Z";
|
||||||
|
const continuation = JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: `compatibility-review-${requestedAt}`,
|
||||||
|
scenarioId: "compatibility-review",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
requestedAt,
|
||||||
|
});
|
||||||
|
const fixture = createFixture({ continuation });
|
||||||
|
let markDeleteStarted!: () => void;
|
||||||
|
const deleteStarted = new Promise<void>((resolve) => {
|
||||||
|
markDeleteStarted = resolve;
|
||||||
|
});
|
||||||
|
let finishDelete!: () => void;
|
||||||
|
const deleting = new Promise<void>((resolve) => {
|
||||||
|
finishDelete = resolve;
|
||||||
|
});
|
||||||
|
fixture.config.delete.mockImplementation(async (key) => {
|
||||||
|
markDeleteStarted();
|
||||||
|
await deleting;
|
||||||
|
fixture.local.delete(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = fixture.settingLoadedHandlers[0]();
|
||||||
|
await deleteStarted;
|
||||||
|
|
||||||
|
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
|
||||||
|
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
|
||||||
|
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
finishDelete();
|
||||||
|
await loading;
|
||||||
|
|
||||||
|
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
|
||||||
|
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
|
||||||
|
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can retry one-shot continuation consumption after a deletion failure", async () => {
|
||||||
|
const requestedAt = "2026-07-18T11:59:00.000Z";
|
||||||
|
const continuation = JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
requestId: `compatibility-review-${requestedAt}`,
|
||||||
|
scenarioId: "compatibility-review",
|
||||||
|
stage: "awaiting-restart",
|
||||||
|
requestedAt,
|
||||||
|
});
|
||||||
|
const fixture = createFixture({ continuation });
|
||||||
|
fixture.config.delete
|
||||||
|
.mockRejectedValueOnce(new Error("continuation delete failed"))
|
||||||
|
.mockImplementation(async (key) => {
|
||||||
|
fixture.local.delete(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fixture.settingLoadedHandlers[0]()).rejects.toThrow("continuation delete failed");
|
||||||
|
|
||||||
|
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
|
||||||
|
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
|
||||||
|
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await fixture.settingLoadedHandlers[0]();
|
||||||
|
|
||||||
|
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
|
||||||
|
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
|
||||||
|
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the actual compatibility controller as the guided review boundary", async () => {
|
it("uses the actual compatibility controller as the guided review boundary", async () => {
|
||||||
const fixture = createFixture();
|
const fixture = createFixture();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||||
import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||||
@@ -168,7 +169,7 @@ describe("WebAppRuntime lifecycle", () => {
|
|||||||
|
|
||||||
it("owns core start, directory scan, status reporting, and shutdown", async () => {
|
it("owns core start, directory scan, status reporting, and shutdown", async () => {
|
||||||
const reportStatus = vi.fn();
|
const reportStatus = vi.fn();
|
||||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
|
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
|
||||||
|
|
||||||
await runtime.start();
|
await runtime.start();
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ describe("WebAppRuntime lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("finalises application readiness after importing local files for optional P2P", async () => {
|
it("finalises application readiness after importing local files for optional P2P", async () => {
|
||||||
const runtime = new WebAppRuntime(createRootHandle());
|
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||||
await runtime.start();
|
await runtime.start();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
|
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
|
||||||
@@ -239,7 +240,7 @@ describe("WebAppRuntime lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not publish readiness after a partial local-file import", async () => {
|
it("does not publish readiness after a partial local-file import", async () => {
|
||||||
const runtime = new WebAppRuntime(createRootHandle());
|
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||||
await runtime.start();
|
await runtime.start();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
|
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
|
||||||
@@ -269,7 +270,7 @@ describe("WebAppRuntime lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not publish readiness when database initialisation rejects manual preparation", async () => {
|
it("does not publish readiness when database initialisation rejects manual preparation", async () => {
|
||||||
const runtime = new WebAppRuntime(createRootHandle());
|
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||||
await runtime.start();
|
await runtime.start();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
runtimeMocks.onDatabaseInitialised.mockResolvedValue(false);
|
runtimeMocks.onDatabaseInitialised.mockResolvedValue(false);
|
||||||
@@ -282,7 +283,7 @@ describe("WebAppRuntime lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not publish readiness when pending file events cannot be committed", async () => {
|
it("does not publish readiness when pending file events cannot be committed", async () => {
|
||||||
const runtime = new WebAppRuntime(createRootHandle());
|
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||||
await runtime.start();
|
await runtime.start();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
runtimeMocks.commitPendingFileEvents.mockResolvedValue(false);
|
runtimeMocks.commitPendingFileEvents.mockResolvedValue(false);
|
||||||
@@ -295,7 +296,7 @@ describe("WebAppRuntime lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not repeat readiness finalisation after a successful manual scan", async () => {
|
it("does not repeat readiness finalisation after a successful manual scan", async () => {
|
||||||
const runtime = new WebAppRuntime(createRootHandle());
|
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||||
await runtime.start();
|
await runtime.start();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
@@ -311,7 +312,7 @@ describe("WebAppRuntime lifecycle", () => {
|
|||||||
it("rejects a failed core start after cleaning up the partial runtime", async () => {
|
it("rejects a failed core start after cleaning up the partial runtime", async () => {
|
||||||
runtimeMocks.onLoad.mockResolvedValue(false);
|
runtimeMocks.onLoad.mockResolvedValue(false);
|
||||||
const reportStatus = vi.fn();
|
const reportStatus = vi.fn();
|
||||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
|
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
|
||||||
|
|
||||||
await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync");
|
await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync");
|
||||||
|
|
||||||
@@ -325,7 +326,7 @@ describe("WebAppRuntime lifecycle", () => {
|
|||||||
|
|
||||||
it("shuts down once before scheduling a host reload", async () => {
|
it("shuts down once before scheduling a host reload", async () => {
|
||||||
const scheduleReload = vi.fn();
|
const scheduleReload = vi.fn();
|
||||||
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload });
|
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload, deviceValues: new MemoryValueStore() });
|
||||||
await runtime.start();
|
await runtime.start();
|
||||||
|
|
||||||
runtimeMocks.options?.restart?.schedule();
|
runtimeMocks.options?.restart?.schedule();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||||
@@ -27,6 +28,7 @@ describe("WebPeer runtime composition", () => {
|
|||||||
translate: (key) => `webpeer:${key}`,
|
translate: (key) => `webpeer:${key}`,
|
||||||
});
|
});
|
||||||
const runtime = new WebPeerRuntime({
|
const runtime = new WebPeerRuntime({
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
context,
|
context,
|
||||||
store: createMemoryStore(),
|
store: createMemoryStore(),
|
||||||
});
|
});
|
||||||
@@ -43,6 +45,7 @@ describe("WebPeer runtime composition", () => {
|
|||||||
|
|
||||||
it("loads settings and opens the local database before announcing layout readiness", async () => {
|
it("loads settings and opens the local database before announcing layout readiness", async () => {
|
||||||
const runtime = new WebPeerRuntime({
|
const runtime = new WebPeerRuntime({
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
store: createMemoryStore(),
|
store: createMemoryStore(),
|
||||||
});
|
});
|
||||||
const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||||
@@ -68,6 +71,7 @@ describe("WebPeer runtime composition", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const runtime = new WebPeerRuntime({
|
const runtime = new WebPeerRuntime({
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
store: createMemoryStore(),
|
store: createMemoryStore(),
|
||||||
});
|
});
|
||||||
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||||
@@ -77,9 +81,7 @@ describe("WebPeer runtime composition", () => {
|
|||||||
P2P_Enabled: true,
|
P2P_Enabled: true,
|
||||||
});
|
});
|
||||||
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true);
|
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true);
|
||||||
const onResumed = vi
|
const onResumed = vi.spyOn(runtime.services.appLifecycle, "onResumed").mockResolvedValue(true);
|
||||||
.spyOn(runtime.services.appLifecycle, "onResumed")
|
|
||||||
.mockResolvedValue(true);
|
|
||||||
const open = vi.spyOn(runtime.p2p.transportLifecycle, "connect").mockResolvedValue(undefined);
|
const open = vi.spyOn(runtime.p2p.transportLifecycle, "connect").mockResolvedValue(undefined);
|
||||||
|
|
||||||
await runtime.start();
|
await runtime.start();
|
||||||
@@ -94,9 +96,11 @@ describe("WebPeer runtime composition", () => {
|
|||||||
|
|
||||||
it("rejects start when the browser-local database cannot be opened", async () => {
|
it("rejects start when the browser-local database cannot be opened", async () => {
|
||||||
const runtime = new WebPeerRuntime({
|
const runtime = new WebPeerRuntime({
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
store: createMemoryStore(),
|
store: createMemoryStore(),
|
||||||
});
|
});
|
||||||
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ ...DEFAULT_SETTINGS });
|
||||||
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false);
|
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false);
|
||||||
|
|
||||||
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
|
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
|
||||||
@@ -104,6 +108,7 @@ describe("WebPeer runtime composition", () => {
|
|||||||
|
|
||||||
it("isolates a specialised runtime and applies its device name before opening the database", async () => {
|
it("isolates a specialised runtime and applies its device name before opening the database", async () => {
|
||||||
const runtime = new WebPeerRuntime({
|
const runtime = new WebPeerRuntime({
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
store: createMemoryStore(),
|
store: createMemoryStore(),
|
||||||
deviceName: " p2p-check-browser-desktop-abc ",
|
deviceName: " p2p-check-browser-desktop-abc ",
|
||||||
systemVaultName: "p2p-check-vault",
|
systemVaultName: "p2p-check-vault",
|
||||||
@@ -114,15 +119,39 @@ describe("WebPeer runtime composition", () => {
|
|||||||
P2P_AutoStart: false,
|
P2P_AutoStart: false,
|
||||||
P2P_Enabled: false,
|
P2P_Enabled: false,
|
||||||
});
|
});
|
||||||
const setSmallConfig = vi.spyOn(runtime.services.config, "setSmallConfig").mockImplementation(() => undefined);
|
let finishWrite!: () => void;
|
||||||
|
const setValue = vi.spyOn(runtime.services.config, "setValue").mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishWrite = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => {
|
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => {
|
||||||
expect(setSmallConfig).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
|
expect(setValue).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
await runtime.start();
|
const starting = runtime.start();
|
||||||
|
await vi.waitFor(() => expect(setValue).toHaveBeenCalledOnce());
|
||||||
|
expect(openDatabase).not.toHaveBeenCalled();
|
||||||
|
finishWrite();
|
||||||
|
await starting;
|
||||||
|
|
||||||
expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault");
|
expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault");
|
||||||
expect(openDatabase).toHaveBeenCalledOnce();
|
expect(openDatabase).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads the current device name after the pane updates the store", async () => {
|
||||||
|
const runtime = new WebPeerRuntime({
|
||||||
|
deviceValues: new MemoryValueStore(),
|
||||||
|
store: createMemoryStore(),
|
||||||
|
systemVaultName: "webpeer-vault",
|
||||||
|
});
|
||||||
|
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ ...DEFAULT_SETTINGS });
|
||||||
|
expect(await runtime.getDeviceName()).toBe("webpeer-vault");
|
||||||
|
await runtime.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, "first-name");
|
||||||
|
expect(await runtime.getDeviceName()).toBe("first-name");
|
||||||
|
await runtime.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, "renamed-device");
|
||||||
|
expect(await runtime.getDeviceName()).toBe("renamed-device");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -17,13 +17,12 @@ export const SERVICE_CONTEXT_MEMBERS = [
|
|||||||
"vault",
|
"vault",
|
||||||
"test",
|
"test",
|
||||||
"UI",
|
"UI",
|
||||||
"config",
|
|
||||||
"keyValueDB",
|
"keyValueDB",
|
||||||
"control",
|
"control",
|
||||||
] as const satisfies readonly Exclude<keyof ServiceHub, "context">[];
|
] as const satisfies readonly Exclude<keyof ServiceHub, "context" | "config">[];
|
||||||
|
|
||||||
export type ServiceContextMember = (typeof SERVICE_CONTEXT_MEMBERS)[number];
|
export type ServiceContextMember = (typeof SERVICE_CONTEXT_MEMBERS)[number];
|
||||||
type MissingServiceContextMember = Exclude<Exclude<keyof ServiceHub, "context">, ServiceContextMember>;
|
type MissingServiceContextMember = Exclude<Exclude<keyof ServiceHub, "context" | "config">, ServiceContextMember>;
|
||||||
const serviceContextMembersAreExhaustive: [MissingServiceContextMember] extends [never] ? true : never = true;
|
const serviceContextMembersAreExhaustive: [MissingServiceContextMember] extends [never] ? true : never = true;
|
||||||
void serviceContextMembersAreExhaustive;
|
void serviceContextMembersAreExhaustive;
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export async function readE2eCompatibilityMarker(
|
|||||||
return await evalObsidianJson<CompatibilityMarkerState>(
|
return await evalObsidianJson<CompatibilityMarkerState>(
|
||||||
cliBinary,
|
cliBinary,
|
||||||
[
|
[
|
||||||
"(()=>{",
|
"(async()=>{",
|
||||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
"const setting=core.services.setting;",
|
"const setting=core.services.setting;",
|
||||||
"const settings=setting.currentSettings();",
|
"const settings=setting.currentSettings();",
|
||||||
@@ -122,7 +122,7 @@ export async function readE2eCompatibilityMarker(
|
|||||||
"return JSON.stringify({",
|
"return JSON.stringify({",
|
||||||
"vaultName,additionalSuffix,expectedStorageKey,",
|
"vaultName,additionalSuffix,expectedStorageKey,",
|
||||||
"rawStorageValue:localStorage.getItem(expectedStorageKey),",
|
"rawStorageValue:localStorage.getItem(expectedStorageKey),",
|
||||||
"serviceValue:setting.getSmallConfig(markerKey),",
|
"serviceValue:await setting.config.getValue(markerKey,''),",
|
||||||
"versionUpFlash:settings.versionUpFlash,",
|
"versionUpFlash:settings.versionUpFlash,",
|
||||||
"});",
|
"});",
|
||||||
"})()",
|
"})()",
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export async function readRuntimeUpgradeState(
|
|||||||
"localDatabaseDocumentCount:databaseInfo.doc_count,",
|
"localDatabaseDocumentCount:databaseInfo.doc_count,",
|
||||||
"nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',",
|
"nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',",
|
||||||
"legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),",
|
"legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),",
|
||||||
"compatibilityMarker:setting.getSmallConfig('database-compatibility-version')??'',",
|
"compatibilityMarker:await (setting.config?.getValue?.('database-compatibility-version','')??Promise.resolve(setting.getSmallConfig?.('database-compatibility-version')??'')),",
|
||||||
"compatibilityStorageEntries:Object.fromEntries(Array.from({length:localStorage.length},(_,index)=>localStorage.key(index))",
|
"compatibilityStorageEntries:Object.fromEntries(Array.from({length:localStorage.length},(_,index)=>localStorage.key(index))",
|
||||||
".filter((key)=>key!==null)",
|
".filter((key)=>key!==null)",
|
||||||
".filter(key=>key.startsWith('obsidian-live-sync-ver')||key.endsWith('-database-compatibility-version'))",
|
".filter(key=>key.startsWith('obsidian-live-sync-ver')||key.endsWith('-database-compatibility-version'))",
|
||||||
@@ -335,7 +335,7 @@ export async function readRuntimeSettingsUpgradeState(
|
|||||||
"const migrationState=setting.getSettingsMigrationState?.();",
|
"const migrationState=setting.getSettingsMigrationState?.();",
|
||||||
"return JSON.stringify({",
|
"return JSON.stringify({",
|
||||||
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',",
|
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',",
|
||||||
"compatibilityMarker:setting.getSmallConfig?.('database-compatibility-version')??'',",
|
"compatibilityMarker:await (setting.config?.getValue?.('database-compatibility-version','')??Promise.resolve(setting.getSmallConfig?.('database-compatibility-version')??'')),",
|
||||||
"migrationState,",
|
"migrationState,",
|
||||||
"settings:{",
|
"settings:{",
|
||||||
"isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,",
|
"isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { Page } from "playwright";
|
||||||
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
|
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||||
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
|
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
|
||||||
|
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { $msg } from "../../../src/common/translation.ts";
|
import { $msg } from "../../../src/common/translation.ts";
|
||||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||||
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||||
@@ -13,18 +15,34 @@ import {
|
|||||||
captureObsidianPage,
|
captureObsidianPage,
|
||||||
obsidianRemoteDebuggingPort,
|
obsidianRemoteDebuggingPort,
|
||||||
openLiveSyncSettings,
|
openLiveSyncSettings,
|
||||||
withObsidianPage,
|
withObsidianPage as withObsidianSessionPage,
|
||||||
} from "../runner/ui.ts";
|
} from "../runner/ui.ts";
|
||||||
import { createTemporaryVault } from "../runner/vault.ts";
|
import { createTemporaryVault } from "../runner/vault.ts";
|
||||||
|
|
||||||
const dialogRunStateKey = "__livesyncE2EDialogMount";
|
const dialogRunStateKey = "__livesyncE2EDialogMount";
|
||||||
|
const savedP2PDeviceName = "e2e-p2p-saved-device";
|
||||||
const repairRunStateKey = "__livesyncE2ETroubleshootingRepair";
|
const repairRunStateKey = "__livesyncE2ETroubleshootingRepair";
|
||||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000);
|
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000);
|
||||||
|
|
||||||
|
async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
|
||||||
|
return await withObsidianSessionPage(port, async (page) => {
|
||||||
|
for (const candidate of page.context().pages()) {
|
||||||
|
const hasPlugin = await candidate
|
||||||
|
.evaluate(
|
||||||
|
() => (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"] !== undefined
|
||||||
|
)
|
||||||
|
.catch(() => false);
|
||||||
|
if (hasPlugin) return await operation(candidate);
|
||||||
|
}
|
||||||
|
throw new Error("The LiveSync Vault renderer is unavailable.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type DialogueMode = "desktop" | "mobile";
|
type DialogueMode = "desktop" | "mobile";
|
||||||
|
|
||||||
type DialogueRunState = {
|
type DialogueRunState = {
|
||||||
done: boolean;
|
done: boolean;
|
||||||
|
deviceNameRead?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
expected?: unknown;
|
expected?: unknown;
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -46,6 +64,14 @@ type LiveSyncTestPlugin = {
|
|||||||
createAllChunks(force: boolean): Promise<unknown>;
|
createAllChunks(force: boolean): Promise<unknown>;
|
||||||
};
|
};
|
||||||
modules: SetupManagerHandle[];
|
modules: SetupManagerHandle[];
|
||||||
|
services: {
|
||||||
|
setting: {
|
||||||
|
config: {
|
||||||
|
getValue(key: string, defaultValue?: string): Promise<string>;
|
||||||
|
setValue(key: string, value: string): Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -91,39 +117,63 @@ async function openRemoteSelectionDialogue(): Promise<void> {
|
|||||||
|
|
||||||
async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
|
async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
|
||||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||||
await page.evaluate((stateKey) => {
|
await page.evaluate(
|
||||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
async ({ stateKey, deviceNameKey, deviceName }) => {
|
||||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||||
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
|
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||||
if (
|
const initialPeerName = plugin.core.settings.P2P_DevicePeerName;
|
||||||
typeof manager?.onSelectServer !== "function" ||
|
if (typeof initialPeerName === "string" && initialPeerName.trim() !== "") {
|
||||||
typeof manager.onConfirmApplySettingsFromWizard !== "function"
|
throw new Error("The P2P setup fixture unexpectedly has an initial device name.");
|
||||||
) {
|
}
|
||||||
throw new Error("Could not find the P2P setup workflow");
|
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
|
||||||
|
if (
|
||||||
|
typeof manager?.onSelectServer !== "function" ||
|
||||||
|
typeof manager.onConfirmApplySettingsFromWizard !== "function"
|
||||||
|
) {
|
||||||
|
throw new Error("Could not find the P2P setup workflow");
|
||||||
|
}
|
||||||
|
const config = plugin.core.services.setting.config;
|
||||||
|
await config.setValue(deviceNameKey, deviceName);
|
||||||
|
const originalGetValue = config.getValue;
|
||||||
|
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
|
||||||
|
const state: DialogueRunState = {
|
||||||
|
kind: "p2p-compatibility-settings",
|
||||||
|
done: false,
|
||||||
|
deviceNameRead: false,
|
||||||
|
};
|
||||||
|
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||||
|
config.getValue = async (key, defaultValue) => {
|
||||||
|
const value = await originalGetValue.call(config, key, defaultValue);
|
||||||
|
if (key === deviceNameKey && value === deviceName) state.deviceNameRead = true;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
|
||||||
|
state.expected = settings;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
void manager
|
||||||
|
.onSelectServer(plugin.core.settings, "unknown")
|
||||||
|
.then(
|
||||||
|
(result) => {
|
||||||
|
state.result = result;
|
||||||
|
state.done = true;
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
state.error = error instanceof Error ? error.message : String(error);
|
||||||
|
state.done = true;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
manager.onConfirmApplySettingsFromWizard = originalConfirm;
|
||||||
|
config.getValue = originalGetValue;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stateKey: dialogRunStateKey,
|
||||||
|
deviceNameKey: SETTING_KEY_P2P_DEVICE_NAME,
|
||||||
|
deviceName: savedP2PDeviceName,
|
||||||
}
|
}
|
||||||
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
|
);
|
||||||
const state: DialogueRunState = { kind: "p2p-compatibility-settings", done: false };
|
|
||||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
|
||||||
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
|
|
||||||
state.expected = settings;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
void manager
|
|
||||||
.onSelectServer(plugin.core.settings, "unknown")
|
|
||||||
.then(
|
|
||||||
(result) => {
|
|
||||||
state.result = result;
|
|
||||||
state.done = true;
|
|
||||||
},
|
|
||||||
(error: unknown) => {
|
|
||||||
state.error = error instanceof Error ? error.message : String(error);
|
|
||||||
state.done = true;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.finally(() => {
|
|
||||||
manager.onConfirmApplySettingsFromWizard = originalConfirm;
|
|
||||||
});
|
|
||||||
}, dialogRunStateKey);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +438,7 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
|
|||||||
if (previousDialogue === null) {
|
if (previousDialogue === null) {
|
||||||
throw new Error("The remote selection dialogue did not expose a close control.");
|
throw new Error("The remote selection dialogue did not expose a close control.");
|
||||||
}
|
}
|
||||||
await modal.locator(".modal-close-button").click({ timeout: uiTimeoutMs });
|
await modal.locator(".modal-close-button, .modal-header-button").last().click({ timeout: uiTimeoutMs });
|
||||||
await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs });
|
await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs });
|
||||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
}
|
}
|
||||||
@@ -495,9 +545,21 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
|||||||
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
||||||
});
|
});
|
||||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
const peerName = modal.locator('input[name="p2p-device-peer-id"]');
|
||||||
|
await peerName.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expectedName) => {
|
||||||
|
const input = document.querySelector('input[name="p2p-device-peer-id"]');
|
||||||
|
return input instanceof HTMLInputElement && !input.disabled && input.value === expectedName;
|
||||||
|
},
|
||||||
|
savedP2PDeviceName,
|
||||||
|
{ timeout: uiTimeoutMs }
|
||||||
|
);
|
||||||
|
if ((await peerName.inputValue()) !== savedP2PDeviceName) {
|
||||||
|
throw new Error("The saved P2P device name was not loaded before submitting the setup dialogue.");
|
||||||
|
}
|
||||||
await modal.locator('input[name="p2p-room-id"]').fill("e2e-p2p-compatibility");
|
await modal.locator('input[name="p2p-room-id"]').fill("e2e-p2p-compatibility");
|
||||||
await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase");
|
await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase");
|
||||||
await modal.locator('input[name="p2p-device-peer-id"]').fill("e2e-device");
|
|
||||||
|
|
||||||
const compatibility = modal.locator("details").filter({
|
const compatibility = modal.locator("details").filter({
|
||||||
has: page.locator("summary").filter({ hasText: "Connection compatibility" }),
|
has: page.locator("summary").filter({ hasText: "Connection compatibility" }),
|
||||||
@@ -565,8 +627,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
|||||||
{ timeout: uiTimeoutMs }
|
{ timeout: uiTimeoutMs }
|
||||||
);
|
);
|
||||||
const resetNotice = compatibility.locator(".sls-info-note-notice").filter({
|
const resetNotice = compatibility.locator(".sls-info-note-notice").filter({
|
||||||
hasText:
|
hasText: "TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
|
||||||
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
|
|
||||||
});
|
});
|
||||||
await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
await resetNotice
|
await resetNotice
|
||||||
@@ -594,10 +655,16 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const state = await assertDialogueRunCompleted();
|
const state = await assertDialogueRunCompleted();
|
||||||
|
if (state.deviceNameRead !== true) {
|
||||||
|
throw new Error("The saved P2P device name was not read through setting.config.");
|
||||||
|
}
|
||||||
if (state.result !== true || typeof state.expected !== "object" || state.expected === null) {
|
if (state.result !== true || typeof state.expected !== "object" || state.expected === null) {
|
||||||
throw new Error("The P2P compatibility settings were not passed to the setup workflow.");
|
throw new Error("The P2P compatibility settings were not passed to the setup workflow.");
|
||||||
}
|
}
|
||||||
const settings = state.expected as Record<string, unknown>;
|
const settings = state.expected as Record<string, unknown>;
|
||||||
|
if (settings.P2P_DevicePeerName !== savedP2PDeviceName) {
|
||||||
|
throw new Error("The saved P2P device name was not passed through the setup workflow.");
|
||||||
|
}
|
||||||
if (settings.P2P_maxWirePayloadBytes !== 800 || settings.P2P_connectionPath !== "relay") {
|
if (settings.P2P_maxWirePayloadBytes !== 800 || settings.P2P_connectionPath !== "relay") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`The P2P compatibility choices did not round-trip through the setup dialogue: ${JSON.stringify({
|
`The P2P compatibility choices did not round-trip through the setup dialogue: ${JSON.stringify({
|
||||||
@@ -672,15 +739,14 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
|
|||||||
const app = (globalThis as ObsidianTestGlobal).app;
|
const app = (globalThis as ObsidianTestGlobal).app;
|
||||||
return Boolean(
|
return Boolean(
|
||||||
app?.plugins?.plugins["obsidian-livesync"]?.core?.settings.isConfigured &&
|
app?.plugins?.plugins["obsidian-livesync"]?.core?.settings.isConfigured &&
|
||||||
app.commands?.commands?.[commandId]
|
app.commands?.commands?.[commandId]
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
"obsidian-livesync:livesync-copysetupuri",
|
"obsidian-livesync:livesync-copysetupuri",
|
||||||
{ timeout: uiTimeoutMs }
|
{ timeout: uiTimeoutMs }
|
||||||
);
|
);
|
||||||
return await page.evaluate(
|
return await page.evaluate(
|
||||||
(commandId) =>
|
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||||
(globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
|
||||||
"obsidian-livesync:livesync-copysetupuri"
|
"obsidian-livesync:livesync-copysetupuri"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -714,7 +780,8 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
|
|||||||
timeout: uiTimeoutMs,
|
timeout: uiTimeoutMs,
|
||||||
});
|
});
|
||||||
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
|
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
|
||||||
state: "visible", timeout: uiTimeoutMs,
|
state: "visible",
|
||||||
|
timeout: uiTimeoutMs,
|
||||||
});
|
});
|
||||||
if (mode === "mobile") await assertMobileDialogueLayout(page, choice, "Setup URI availability dialogue");
|
if (mode === "mobile") await assertMobileDialogueLayout(page, choice, "Setup URI availability dialogue");
|
||||||
await choice.getByRole("button", { name: selected, exact: true }).click({ timeout: uiTimeoutMs });
|
await choice.getByRole("button", { name: selected, exact: true }).click({ timeout: uiTimeoutMs });
|
||||||
@@ -866,6 +933,7 @@ async function verifyCompatibleAlignmentSettingDefault(): Promise<void> {
|
|||||||
if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) {
|
if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) {
|
||||||
throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined.");
|
throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined.");
|
||||||
}
|
}
|
||||||
|
await settingsNavigator.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1094,9 +1162,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||||
const liveSyncSettings = page.locator(".sls-setting");
|
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||||
|
const liveSyncSettings = await settingsNavigator.openPage("Hatch");
|
||||||
const logSetting = liveSyncSettings.locator(".setting-item").filter({
|
const logSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||||
has: page.getByText("Write logs into the file", { exact: true }),
|
has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
|
||||||
});
|
});
|
||||||
await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
|
await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
@@ -1130,10 +1199,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
|||||||
|
|
||||||
// Saving a toggle refreshes the settings pane. Resolve the visible control again so the
|
// Saving a toggle refreshes the settings pane. Resolve the visible control again so the
|
||||||
// second action does not target the detached pre-save element.
|
// second action does not target the detached pre-save element.
|
||||||
const refreshedLogToggle = page
|
const refreshedLogToggle = liveSyncSettings
|
||||||
.locator(".sls-setting:visible .setting-item:visible")
|
.locator(".setting-item:visible")
|
||||||
.filter({
|
.filter({
|
||||||
has: page.getByText("Write logs into the file", { exact: true }),
|
has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
|
||||||
})
|
})
|
||||||
.locator(".checkbox-container:visible")
|
.locator(".checkbox-container:visible")
|
||||||
.last();
|
.last();
|
||||||
@@ -1179,13 +1248,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, repairRunStateKey);
|
}, repairRunStateKey);
|
||||||
await page
|
await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).click({
|
||||||
.locator(".sls-setting:visible")
|
timeout: uiTimeoutMs,
|
||||||
.last()
|
});
|
||||||
.getByRole("button", { name: "Recreate current chunks", exact: true })
|
|
||||||
.click({
|
|
||||||
timeout: uiTimeoutMs,
|
|
||||||
});
|
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(stateKey) =>
|
(stateKey) =>
|
||||||
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]?.done === true,
|
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]?.done === true,
|
||||||
@@ -1200,13 +1265,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
|||||||
throw new Error(`Recreate missing chunks failed: ${repairState.error}`);
|
throw new Error(`Recreate missing chunks failed: ${repairState.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page
|
await liveSyncSettings.getByRole("button", { name: "Begin inspection", exact: true }).click({
|
||||||
.locator(".sls-setting:visible")
|
timeout: uiTimeoutMs,
|
||||||
.last()
|
});
|
||||||
.getByRole("button", { name: "Begin inspection", exact: true })
|
|
||||||
.click({
|
|
||||||
timeout: uiTimeoutMs,
|
|
||||||
});
|
|
||||||
await page
|
await page
|
||||||
.locator(".notice")
|
.locator(".notice")
|
||||||
.filter({ hasText: /^done$/u })
|
.filter({ hasText: /^done$/u })
|
||||||
@@ -1214,6 +1275,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
|||||||
state: "visible",
|
state: "visible",
|
||||||
timeout: uiTimeoutMs,
|
timeout: uiTimeoutMs,
|
||||||
});
|
});
|
||||||
|
await settingsNavigator.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
return screenshotPath;
|
return screenshotPath;
|
||||||
|
|||||||
@@ -372,13 +372,21 @@ async function restartAndResumeHarness(): Promise<string> {
|
|||||||
await harness
|
await harness
|
||||||
.locator('[data-testid="review-harness-resumed"]')
|
.locator('[data-testid="review-harness-resumed"]')
|
||||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
const continuationRemoved = await page.evaluate((stateKey) => {
|
const continuationRemoved = await page.evaluate(async (stateKey) => {
|
||||||
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||||
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
|
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
|
||||||
throw new Error("Self-hosted LiveSync is unavailable after restart.");
|
throw new Error("Self-hosted LiveSync is unavailable after restart.");
|
||||||
}
|
}
|
||||||
const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } }).core;
|
const core = (
|
||||||
return core.services.setting.getSmallConfig(stateKey) === "";
|
plugin as {
|
||||||
|
core: {
|
||||||
|
services: {
|
||||||
|
setting: { config: { getValue(key: string, defaultValue: string): Promise<string> } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).core;
|
||||||
|
return (await core.services.setting.config.getValue(stateKey, "")) === "";
|
||||||
}, REVIEW_HARNESS_STATE_KEY);
|
}, REVIEW_HARNESS_STATE_KEY);
|
||||||
if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use.");
|
if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use.");
|
||||||
await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" });
|
await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" });
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ type LiveSyncTestPlugin = {
|
|||||||
usePowerUserMode: boolean;
|
usePowerUserMode: boolean;
|
||||||
useEdgeCaseMode: boolean;
|
useEdgeCaseMode: boolean;
|
||||||
hashCacheMaxCount: number;
|
hashCacheMaxCount: number;
|
||||||
|
customChunkSize: number;
|
||||||
};
|
};
|
||||||
getSmallConfig(key: string): string | null;
|
config: { getValue(key: string, defaultValue: string): Promise<string> };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -484,12 +485,12 @@ async function verifyCompatibilityReview(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await withObsidianPage(port, async (page) => {
|
await withObsidianPage(port, async (page) => {
|
||||||
const markerBeforeAcknowledgement = await page.evaluate(() => {
|
const markerBeforeAcknowledgement = await page.evaluate(async () => {
|
||||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
|
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
|
||||||
return plugin.core.services.setting.getSmallConfig("database-compatibility-version");
|
return await plugin.core.services.setting.config.getValue("database-compatibility-version", "");
|
||||||
});
|
});
|
||||||
if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") {
|
if (markerBeforeAcknowledgement !== "") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
|
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
|
||||||
);
|
);
|
||||||
@@ -579,12 +580,12 @@ async function verifyCompatibilityReview(): Promise<void> {
|
|||||||
.click();
|
.click();
|
||||||
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(expectedVersion) => {
|
async (expectedVersion) => {
|
||||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||||
if (plugin === undefined) return false;
|
if (plugin === undefined) return false;
|
||||||
const setting = plugin.core.services.setting;
|
const setting = plugin.core.services.setting;
|
||||||
return (
|
return (
|
||||||
setting.getSmallConfig("database-compatibility-version") === expectedVersion &&
|
(await setting.config.getValue("database-compatibility-version", "")) === expectedVersion &&
|
||||||
setting.currentSettings().versionUpFlash === ""
|
setting.currentSettings().versionUpFlash === ""
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -611,10 +612,16 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
|
|||||||
state: "visible",
|
state: "visible",
|
||||||
timeout: uiTimeoutMs,
|
timeout: uiTimeoutMs,
|
||||||
});
|
});
|
||||||
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
|
const chunkSize = await page.evaluate(() => {
|
||||||
state: "visible",
|
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||||
timeout: uiTimeoutMs,
|
return plugin?.core.services.setting.currentSettings().customChunkSize;
|
||||||
});
|
});
|
||||||
|
if (chunkSize !== 0) {
|
||||||
|
throw new Error("Compatibility review must retain the fixture's zero chunk-size value.");
|
||||||
|
}
|
||||||
|
if ((await doctor.getByText("Enhance chunk size", { exact: true }).count()) !== 0) {
|
||||||
|
throw new Error("Config Doctor must accept the recommended zero chunk-size value.");
|
||||||
|
}
|
||||||
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
||||||
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
||||||
}
|
}
|
||||||
@@ -978,6 +985,7 @@ function createSettingsPluginData(settingsOnlyRun: boolean): Record<string, unkn
|
|||||||
liveSync: false,
|
liveSync: false,
|
||||||
versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage,
|
versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage,
|
||||||
notifyThresholdOfRemoteStorageSize: 0,
|
notifyThresholdOfRemoteStorageSize: 0,
|
||||||
|
customChunkSize: 0,
|
||||||
syncOnStart: false,
|
syncOnStart: false,
|
||||||
syncOnSave: false,
|
syncOnSave: false,
|
||||||
syncOnEditorSave: false,
|
syncOnEditorSave: false,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
- Unreadable or malformed device-local state now stops initialisation instead of being treated as empty. Existing valid state files keep their format.
|
||||||
|
|
||||||
## 1.0.39
|
## 1.0.39
|
||||||
|
|
||||||
20th October, 2026
|
20th October, 2026
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Keep CouchDB database-version negotiation isolated from Setup URI generation.
|
// Keep CouchDB database-version negotiation isolated from Setup URI generation.
|
||||||
// The exact release must match utils/livesync-commonlib-version.ts; the setup
|
// The exact release must match utils/livesync-commonlib-version.ts; the setup
|
||||||
// tool suite checks every static specifier before release.
|
// tool suite checks every static specifier before release.
|
||||||
export { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.1.34/compat/pouchdb/negotiation";
|
export { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.2.0/compat/pouchdb/negotiation";
|
||||||
export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.34/compat/pouchdb/pouchdb-browser";
|
export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.2.0/compat/pouchdb/pouchdb-browser";
|
||||||
Generated
+50
-7
@@ -1,9 +1,25 @@
|
|||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"specifiers": {
|
"specifiers": {
|
||||||
"npm:@vrtmrz/livesync-commonlib@0.1.34": "0.1.34"
|
"npm:@vrtmrz/livesync-commonlib@0.2.0": "0.2.0"
|
||||||
},
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
|
"@aws-crypto/sha256-js@5.2.0": {
|
||||||
|
"integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@aws-crypto/util",
|
||||||
|
"@aws-sdk/types",
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@aws-crypto/util@5.2.0": {
|
||||||
|
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@aws-sdk/types",
|
||||||
|
"@smithy/util-utf8",
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@aws-sdk/checksums@3.1000.18": {
|
"@aws-sdk/checksums@3.1000.18": {
|
||||||
"integrity": "sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==",
|
"integrity": "sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -230,6 +246,12 @@
|
|||||||
"tslib"
|
"tslib"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@smithy/is-array-buffer@2.2.0": {
|
||||||
|
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
|
||||||
|
"dependencies": [
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@smithy/md5-js@4.4.10": {
|
"@smithy/md5-js@4.4.10": {
|
||||||
"integrity": "sha512-XI5xhWxRkWuiLNj0/Z30vLTPpZe9UQcg57Ox/n4vzGmFiHSrD+xAmx6ubEcWt6u69IOH+4LAucpaZk5AMxB8oQ==",
|
"integrity": "sha512-XI5xhWxRkWuiLNj0/Z30vLTPpZe9UQcg57Ox/n4vzGmFiHSrD+xAmx6ubEcWt6u69IOH+4LAucpaZk5AMxB8oQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -267,6 +289,13 @@
|
|||||||
"tslib"
|
"tslib"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@smithy/util-buffer-from@2.2.0": {
|
||||||
|
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@smithy/is-array-buffer",
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@smithy/util-retry@4.5.10": {
|
"@smithy/util-retry@4.5.10": {
|
||||||
"integrity": "sha512-hYu5ieq8myuO29xCQV0IIhicRm1aO0lcNBeMcF1mVVAVQC0oylPGKFliMq5NbxtdOvRxJWCMuir2gwKI91f1lw==",
|
"integrity": "sha512-hYu5ieq8myuO29xCQV0IIhicRm1aO0lcNBeMcF1mVVAVQC0oylPGKFliMq5NbxtdOvRxJWCMuir2gwKI91f1lw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -274,6 +303,13 @@
|
|||||||
"tslib"
|
"tslib"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@smithy/util-utf8@2.3.0": {
|
||||||
|
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
|
||||||
|
"dependencies": [
|
||||||
|
"@smithy/util-buffer-from",
|
||||||
|
"tslib"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@trystero-p2p/core@0.25.3": {
|
"@trystero-p2p/core@0.25.3": {
|
||||||
"integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ=="
|
"integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ=="
|
||||||
},
|
},
|
||||||
@@ -284,9 +320,10 @@
|
|||||||
"@trystero-p2p/core"
|
"@trystero-p2p/core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@vrtmrz/livesync-commonlib@0.1.34": {
|
"@vrtmrz/livesync-commonlib@0.2.0": {
|
||||||
"integrity": "sha512-EeVpeFg3W43cpN+x0BZvFj9fN+eQFil9vohvmLLV1z/8bCTT0Hsq/bfRV59oL0qFNggPetkwyCZUusdfjajNOw==",
|
"integrity": "sha512-K24Pkke+FTjZI7DjIV7qkZk+BmC9VebN3da+2cVD9SWOgoGl7OvDyXTHDafbrmmM4vi4ydaydAEh9glO1xHF6A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
"@aws-crypto/sha256-js",
|
||||||
"@aws-sdk/client-s3",
|
"@aws-sdk/client-s3",
|
||||||
"@smithy/fetch-http-handler",
|
"@smithy/fetch-http-handler",
|
||||||
"@smithy/md5-js",
|
"@smithy/md5-js",
|
||||||
@@ -294,6 +331,7 @@
|
|||||||
"@smithy/types",
|
"@smithy/types",
|
||||||
"@smithy/util-retry",
|
"@smithy/util-retry",
|
||||||
"@trystero-p2p/nostr",
|
"@trystero-p2p/nostr",
|
||||||
|
"@vrtmrz/value-store",
|
||||||
"diff-match-patch",
|
"diff-match-patch",
|
||||||
"events",
|
"events",
|
||||||
"fflate",
|
"fflate",
|
||||||
@@ -305,9 +343,11 @@
|
|||||||
"pouchdb-adapter-idb",
|
"pouchdb-adapter-idb",
|
||||||
"pouchdb-adapter-indexeddb",
|
"pouchdb-adapter-indexeddb",
|
||||||
"pouchdb-adapter-memory",
|
"pouchdb-adapter-memory",
|
||||||
|
"pouchdb-checkpointer",
|
||||||
"pouchdb-core",
|
"pouchdb-core",
|
||||||
"pouchdb-errors",
|
"pouchdb-errors",
|
||||||
"pouchdb-find",
|
"pouchdb-find",
|
||||||
|
"pouchdb-generate-replication-id",
|
||||||
"pouchdb-mapreduce",
|
"pouchdb-mapreduce",
|
||||||
"pouchdb-merge",
|
"pouchdb-merge",
|
||||||
"pouchdb-replication",
|
"pouchdb-replication",
|
||||||
@@ -317,6 +357,9 @@
|
|||||||
"xxhash-wasm-102@npm:xxhash-wasm@1.1.0"
|
"xxhash-wasm-102@npm:xxhash-wasm@1.1.0"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@vrtmrz/value-store@0.1.0": {
|
||||||
|
"integrity": "sha512-M62f1j0bnJkqpgI5sYxnkXUAOSDEwQbYrLf+w58gCCm4sjIXDmFzDJd2duXabRbktBlACt9P99yjN/9gOAPxrA=="
|
||||||
|
},
|
||||||
"abstract-leveldown@2.7.2": {
|
"abstract-leveldown@2.7.2": {
|
||||||
"integrity": "sha512-+OVvxH2rHVEhWLdbudP6p0+dNMXu8JA1CbhP19T8paTYAcX7oJ4OVjT+ZUVpv7mITxXHqDMej+GdqXBmXkw09w==",
|
"integrity": "sha512-+OVvxH2rHVEhWLdbudP6p0+dNMXu8JA1CbhP19T8paTYAcX7oJ4OVjT+ZUVpv7mITxXHqDMej+GdqXBmXkw09w==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -497,8 +540,8 @@
|
|||||||
],
|
],
|
||||||
"deprecated": true
|
"deprecated": true
|
||||||
},
|
},
|
||||||
"minimatch@10.2.5": {
|
"minimatch@10.2.6": {
|
||||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"brace-expansion"
|
"brace-expansion"
|
||||||
]
|
]
|
||||||
@@ -509,8 +552,8 @@
|
|||||||
"whatwg-url"
|
"whatwg-url"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"octagonal-wheels@0.1.54": {
|
"octagonal-wheels@0.1.55": {
|
||||||
"integrity": "sha512-Je3ancYhjKX7UY2K19T/qTjG8C9nK8YVrACr5naIf78mN4bbjQkYyWmlj+ooifV/moWVsQrp4fEWz/7mv6It3A==",
|
"integrity": "sha512-mHsQv/h4Tw4ZGJCmTxXFlgeRvPuL9s47C0arD8Njqld+DKr3ppaWjVteEBP+9bWFSOpv/a1u2yY3LYNFGmuexg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"idb"
|
"idb"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
decodeSettingsFromSetupURI,
|
decodeSettingsFromSetupURI,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
|
PREFERRED_SETTING_SELF_HOSTED,
|
||||||
} from "../setup/livesync-commonlib.ts";
|
} from "../setup/livesync-commonlib.ts";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): asserts condition {
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
@@ -38,7 +39,9 @@ Deno.test("generates a current self-hosted Setup URI through the published Commo
|
|||||||
const recoveryCode = stdout.match(/sls-id-v1:[0-9a-f]{64}/u)?.[0];
|
const recoveryCode = stdout.match(/sls-id-v1:[0-9a-f]{64}/u)?.[0];
|
||||||
assert(recoveryCode, "the generator did not print an ID recovery code");
|
assert(recoveryCode, "the generator did not print an ID recovery code");
|
||||||
assert(
|
assert(
|
||||||
(effectiveSettings as typeof effectiveSettings & { idDerivationKey?: string }).idDerivationKey ===
|
(effectiveSettings as typeof effectiveSettings & {
|
||||||
|
idDerivationKey?: string;
|
||||||
|
}).idDerivationKey ===
|
||||||
recoveryCode.slice("sls-id-v1:".length),
|
recoveryCode.slice("sls-id-v1:".length),
|
||||||
"the CouchDB Setup URI did not contain the generated ID key",
|
"the CouchDB Setup URI did not contain the generated ID key",
|
||||||
);
|
);
|
||||||
@@ -47,8 +50,9 @@ Deno.test("generates a current self-hosted Setup URI through the published Commo
|
|||||||
"the CouchDB Setup URI left the imported device unconfigured",
|
"the CouchDB Setup URI left the imported device unconfigured",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
effectiveSettings.customChunkSize === 60,
|
effectiveSettings.customChunkSize ===
|
||||||
"the Setup URI did not use the current self-hosted chunk-size recommendation",
|
PREFERRED_SETTING_SELF_HOSTED.customChunkSize,
|
||||||
|
"the Setup URI did not use the self-hosted chunk-size preference from Commonlib",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
effectiveSettings.chunkSplitterVersion === "v3-rabin-karp",
|
effectiveSettings.chunkSplitterVersion === "v3-rabin-karp",
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
// Commonlib registry release. Static npm specifiers cannot interpolate this
|
// Commonlib registry release. Static npm specifiers cannot interpolate this
|
||||||
// value, so livesync-commonlib-version.test.ts verifies the domain-specific
|
// value, so livesync-commonlib-version.test.ts verifies the domain-specific
|
||||||
// facades against it.
|
// facades against it.
|
||||||
export const LIVESYNC_COMMONLIB_VERSION = "0.1.34";
|
export const LIVESYNC_COMMONLIB_VERSION = "0.2.0";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
decodeSettingsFromSetupURI,
|
decodeSettingsFromSetupURI,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
|
PREFERRED_JOURNAL_SYNC,
|
||||||
} from "./livesync-commonlib.ts";
|
} from "./livesync-commonlib.ts";
|
||||||
import { generateSetupURI } from "./generate_setup_uri.ts";
|
import { generateSetupURI } from "./generate_setup_uri.ts";
|
||||||
|
|
||||||
@@ -55,8 +56,8 @@ Deno.test("generates an Object Storage Setup URI with a selected S3 profile", as
|
|||||||
"the Setup URI left the imported device unconfigured",
|
"the Setup URI left the imported device unconfigured",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
effective.customChunkSize === 10,
|
effective.customChunkSize === PREFERRED_JOURNAL_SYNC.customChunkSize,
|
||||||
"the journal chunk-size preset was not applied",
|
"the Object Storage Setup URI did not use its Commonlib journal chunk-size preference",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
effective.liveSync,
|
effective.liveSync,
|
||||||
@@ -122,7 +123,7 @@ Deno.test("generates a random-room P2P Setup URI without copying a device identi
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
effective.customChunkSize === 0,
|
effective.customChunkSize === 0,
|
||||||
"the P2P profile inherited the self-hosted CouchDB chunk-size recommendation",
|
"the P2P Setup URI did not retain its zero chunk-size value",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
effective.sendChunksBulkMaxSize === 1,
|
effective.sendChunksBulkMaxSize === 1,
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ export {
|
|||||||
decodeSettingsFromSetupURI,
|
decodeSettingsFromSetupURI,
|
||||||
encodeTimeBoundSetupURI,
|
encodeTimeBoundSetupURI,
|
||||||
isTimeBoundSetupURIUsableNow,
|
isTimeBoundSetupURIUsableNow,
|
||||||
} from "npm:@vrtmrz/livesync-commonlib@0.1.34/setup-uri";
|
} from "npm:@vrtmrz/livesync-commonlib@0.2.0/setup-uri";
|
||||||
export type { TimeBoundSetupURIMode } from "npm:@vrtmrz/livesync-commonlib@0.1.34/setup-uri";
|
export type { TimeBoundSetupURIMode } from "npm:@vrtmrz/livesync-commonlib@0.2.0/setup-uri";
|
||||||
export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.34/compat/common/utils";
|
export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.2.0/compat/common/utils";
|
||||||
export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.34/remote-configurations";
|
export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.2.0/remote-configurations";
|
||||||
export {
|
export {
|
||||||
createNewVaultSettings,
|
createNewVaultSettings,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
@@ -16,5 +16,5 @@ export {
|
|||||||
PREFERRED_BASE,
|
PREFERRED_BASE,
|
||||||
PREFERRED_JOURNAL_SYNC,
|
PREFERRED_JOURNAL_SYNC,
|
||||||
PREFERRED_SETTING_SELF_HOSTED,
|
PREFERRED_SETTING_SELF_HOSTED,
|
||||||
} from "npm:@vrtmrz/livesync-commonlib@0.1.34/settings";
|
} from "npm:@vrtmrz/livesync-commonlib@0.2.0/settings";
|
||||||
export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.34/settings";
|
export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.2.0/settings";
|
||||||
Reference in new issue
Block a user