mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-24 21:42:58 +00:00
Improve troubleshooting and compatible setting handling
This commit is contained in:
@@ -38,6 +38,11 @@ Before submitting a pull request, you must run verification scripts locally to e
|
||||
```bash
|
||||
npm run test:unit
|
||||
```
|
||||
- When changing the troubleshooting or recovery guides, inspect their current English UI labels and local references:
|
||||
```bash
|
||||
npm run inspect:troubleshooting
|
||||
```
|
||||
This read-only Inspector prints JSON containing `ok`, `checkedFiles`, `checkedLocalReferences`, and `errors`, and exits unsuccessfully when a contract is stale.
|
||||
|
||||
If you have the capability and a suitable environment (such as Linux and Docker), running the CLI End-to-End (E2E) tests is also highly appreciated. Instructions are detailed in [devs.md](devs.md). If you cannot run E2E tests locally, please explicitly ask to run the tests on the CI by stating 'Please run CI tests' in your pull request description.
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
type InspectionError = {
|
||||
check: "current-label" | "local-reference" | "retired-label";
|
||||
file: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type TroubleshootingDocsInspection = {
|
||||
ok: boolean;
|
||||
checkedFiles: string[];
|
||||
checkedLocalReferences: number;
|
||||
errors: InspectionError[];
|
||||
};
|
||||
|
||||
const guidePaths = ["docs/troubleshooting.md", "docs/recovery.md", "docs/tips/p2p-sync-tips.md"] as const;
|
||||
const messageCataloguePath = "src/common/messagesJson/en.json";
|
||||
const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu;
|
||||
|
||||
function repositoryRootFromThisFile(): string {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
}
|
||||
|
||||
function normaliseReferenceTarget(rawTarget: string): string {
|
||||
const withoutAngles = rawTarget.startsWith("<") && rawTarget.endsWith(">") ? rawTarget.slice(1, -1) : rawTarget;
|
||||
return decodeURIComponent(withoutAngles);
|
||||
}
|
||||
|
||||
function isExternalReference(target: string): boolean {
|
||||
return /^(?:https?:|mailto:|obsidian:)/u.test(target);
|
||||
}
|
||||
|
||||
async function inspectLocalReferences(
|
||||
repositoryRoot: string,
|
||||
documentPath: string,
|
||||
document: string,
|
||||
errors: InspectionError[]
|
||||
): Promise<number> {
|
||||
let checked = 0;
|
||||
for (const match of document.matchAll(markdownLinkPattern)) {
|
||||
const rawTarget = match[1];
|
||||
if (!rawTarget) continue;
|
||||
const target = normaliseReferenceTarget(rawTarget);
|
||||
if (isExternalReference(target) || target.startsWith("#")) continue;
|
||||
|
||||
const [pathPart] = target.split("#", 1);
|
||||
if (!pathPart) continue;
|
||||
checked++;
|
||||
const referencedPath = resolve(repositoryRoot, dirname(documentPath), pathPart);
|
||||
try {
|
||||
await access(referencedPath);
|
||||
} catch {
|
||||
errors.push({
|
||||
check: "local-reference",
|
||||
file: documentPath,
|
||||
detail: `Missing local reference: ${relative(repositoryRoot, referencedPath)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
|
||||
export async function inspectTroubleshootingDocs(
|
||||
repositoryRoot = repositoryRootFromThisFile()
|
||||
): Promise<TroubleshootingDocsInspection> {
|
||||
const errors: InspectionError[] = [];
|
||||
const documents = new Map<string, string>();
|
||||
for (const guidePath of guidePaths) {
|
||||
documents.set(guidePath, await readFile(resolve(repositoryRoot, guidePath), "utf8"));
|
||||
}
|
||||
|
||||
const troubleshooting = documents.get("docs/troubleshooting.md")!;
|
||||
const catalogue = JSON.parse(await readFile(resolve(repositoryRoot, messageCataloguePath), "utf8")) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const requiredMessageKeys = [
|
||||
"TweakMismatchResolve.Action.UseConfigured",
|
||||
"TweakMismatchResolve.Action.UseMine",
|
||||
"TweakMismatchResolve.Action.UseRemote",
|
||||
"TweakMismatchResolve.Action.Dismiss",
|
||||
"obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown",
|
||||
] as const;
|
||||
|
||||
for (const messageKey of requiredMessageKeys) {
|
||||
const label = catalogue[messageKey];
|
||||
if (!label) {
|
||||
errors.push({
|
||||
check: "current-label",
|
||||
file: messageCataloguePath,
|
||||
detail: `The English message catalogue does not define ${messageKey}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!troubleshooting.includes(label)) {
|
||||
errors.push({
|
||||
check: "current-label",
|
||||
file: "docs/troubleshooting.md",
|
||||
detail: `The guide does not include the current UI label '${label}'.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const retiredLabel of ["`Update with mine`", "`Use configured`", "`Sync settings via Markdown files`"]) {
|
||||
if (troubleshooting.includes(retiredLabel)) {
|
||||
errors.push({
|
||||
check: "retired-label",
|
||||
file: "docs/troubleshooting.md",
|
||||
detail: `The guide still includes the retired label ${retiredLabel}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let checkedLocalReferences = 0;
|
||||
for (const [guidePath, document] of documents) {
|
||||
checkedLocalReferences += await inspectLocalReferences(repositoryRoot, guidePath, document, errors);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
checkedFiles: [...guidePaths],
|
||||
checkedLocalReferences,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
async function runCli(): Promise<void> {
|
||||
const result = await inspectTroubleshootingDocs();
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined;
|
||||
if (invokedPath === import.meta.url) {
|
||||
await runCli();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inspectTroubleshootingDocs } from "./inspect-troubleshooting-docs";
|
||||
|
||||
describe("troubleshooting documentation contract", () => {
|
||||
it("uses current English UI labels and resolves every local guide reference", async () => {
|
||||
const result = await inspectTroubleshootingDocs();
|
||||
|
||||
expect(result.checkedFiles).toEqual([
|
||||
"docs/troubleshooting.md",
|
||||
"docs/recovery.md",
|
||||
"docs/tips/p2p-sync-tips.md",
|
||||
]);
|
||||
expect(result.checkedLocalReferences).toBeGreaterThan(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -959,6 +959,12 @@ If disabled(toggled), chunks will be split on the UI thread (Previous behaviour)
|
||||
Setting key: processSmallFilesInUIThread
|
||||
If enabled, the file under 1kb will be processed in the UI thread.
|
||||
|
||||
#### Automatically align compatible chunk settings
|
||||
|
||||
Setting key: autoAcceptCompatibleTweak
|
||||
|
||||
Current releases enable this by default when the differences are limited to compatible chunk settings. The side with the newer recorded modification time is used for the chunk hash algorithm, chunk size, or splitter version; the remote value is used when neither side has a recorded time or the times are equal. No dialogue or database reconstruction is required. Existing content remains readable, but changing these values can reduce chunk reuse. Turn this off to review compatible differences manually. Any difference which also involves an incompatible setting always requires an explicit decision.
|
||||
|
||||
### 8. Compatibility (Trouble addressed)
|
||||
|
||||
#### Do not check configuration mismatch before replication
|
||||
|
||||
@@ -61,9 +61,14 @@ If the log reports missing chunks or a size mismatch:
|
||||
|
||||
Some settings must match across devices. LiveSync pauses synchronisation when the local and remote values differ rather than propagating an unexpected change silently.
|
||||
|
||||
- Choose `Update with mine` only when this device's setting is the intended shared value.
|
||||
- Choose `Use configured` to accept the value already stored for the synchronisation group.
|
||||
- `Dismiss` postpones the decision, but synchronisation remains paused until it is resolved.
|
||||
Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision.
|
||||
|
||||
The available actions depend on when the mismatch is found:
|
||||
|
||||
- While checking a remote profile, `Use configured settings` accepts the shared values already stored in that remote. `Dismiss` leaves this device's settings unchanged.
|
||||
- For a mismatch found before synchronisation, `Apply settings to this device` accepts the remote values. Choose `Update remote database settings` only when this device's values are intended to become the shared values.
|
||||
- When the change requires local or remote reconstruction, the action itself states that Fetch or Rebuild will follow. Make sure that the intended authoritative copy is available before choosing it.
|
||||
- `Dismiss` postpones a mismatch found before synchronisation. Synchronisation remains paused until the mismatch is resolved.
|
||||
|
||||

|
||||
|
||||
@@ -75,7 +80,7 @@ Historic defect notices and renamed controls are retained in the [0.25 release h
|
||||
|
||||
Generate an encrypted Setup URI from a working device. This preserves the intended remote profiles and selections while allowing the additional device to keep its own device-specific name. Store the URI and its passphrase separately.
|
||||
|
||||
For deliberate setting changes during normal use, use `Sync settings via Markdown files` under `Sync settings`.
|
||||
For deliberate setting changes during normal use, use `Sync Settings via Markdown` under `Sync settings`.
|
||||
|
||||
### Choose a Setup URI passphrase
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 32 KiB |
@@ -29,6 +29,7 @@
|
||||
"i18n:json2yaml": "tsx _tools/json2yaml.ts",
|
||||
"i18n:yaml2json": "tsx _tools/yaml2json.ts",
|
||||
"test:unit": "vitest run --config vitest.config.unit.ts",
|
||||
"inspect:troubleshooting": "tsx _tools/inspect-troubleshooting-docs.ts",
|
||||
"test:setup-tools": "bash utils/flyio/setenv.test.sh && deno test -A --config=utils/flyio/deno.jsonc --frozen --lock=utils/flyio/deno.lock utils/livesync-commonlib-version.test.ts utils/couchdb/provision.test.ts utils/setup/generate_setup_uri.test.ts utils/flyio/generate_setupuri.test.ts",
|
||||
"test:contract:contexts": "vitest run --config vitest.config.unit.ts src/modules/services/ObsidianServiceContext.unit.spec.ts src/apps/cli/services/NodeServiceContext.unit.spec.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts",
|
||||
"test:contract:context:webapp": "vitest run --config vitest.config.unit.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts",
|
||||
|
||||
@@ -29,8 +29,6 @@ function valueToString(value: string | number | boolean | object | undefined): s
|
||||
}
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
private _hasNotifiedAutoAcceptCompatibleUndefined = false;
|
||||
|
||||
private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial<TweakValues>) {
|
||||
const items = Object.keys(
|
||||
TweakValuesShouldMatchedTemplate
|
||||
@@ -64,33 +62,17 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
);
|
||||
if (!hasOnlyCompatibleLossyMismatches) return undefined;
|
||||
|
||||
let autoAcceptCompatibleTweak = this.settings.autoAcceptCompatibleTweak;
|
||||
if (this.settings.autoAcceptCompatibleTweak === undefined) {
|
||||
if (this._hasNotifiedAutoAcceptCompatibleUndefined) {
|
||||
return undefined;
|
||||
}
|
||||
this._hasNotifiedAutoAcceptCompatibleUndefined = true;
|
||||
const CHOICE_ENABLE = $msg("TweakMismatchResolve.Action.EnableAutoAcceptCompatible");
|
||||
const CHOICE_DISABLE = $msg("TweakMismatchResolve.Action.DisableAutoAcceptCompatible");
|
||||
const CHOICES = [CHOICE_ENABLE, CHOICE_DISABLE] as const;
|
||||
const message = $msg("TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
|
||||
title: $msg("TweakMismatchResolve.Title.AutoAcceptCompatible"),
|
||||
timeout: 0,
|
||||
defaultAction: CHOICE_ENABLE,
|
||||
});
|
||||
if (ret !== CHOICE_ENABLE) {
|
||||
return undefined;
|
||||
}
|
||||
await this.services.setting.applyPartial(
|
||||
{
|
||||
autoAcceptCompatibleTweak: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
Logger("Auto-accept for compatible tweak mismatch has been enabled.");
|
||||
// Keep the settings object stable: settings panes and an in-flight replication retry can
|
||||
// retain this reference while the default is persisted.
|
||||
this.settings.autoAcceptCompatibleTweak = true;
|
||||
await this.services.setting.saveSettingData();
|
||||
autoAcceptCompatibleTweak = true;
|
||||
Logger("Automatic alignment of compatible chunk settings has been enabled.");
|
||||
}
|
||||
|
||||
if (this.settings.autoAcceptCompatibleTweak !== true) return undefined;
|
||||
if (autoAcceptCompatibleTweak !== true) return undefined;
|
||||
return this._selectNewerTweakSide(current, preferred);
|
||||
}
|
||||
|
||||
@@ -215,7 +197,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
} else if (rebuildRecommended) {
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [true, true]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
|
||||
} else {
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
|
||||
@@ -255,9 +237,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
if (conf) {
|
||||
this.settings = { ...this.settings, ...conf };
|
||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
||||
// ReplicationService retains the current settings object while it performs the immediate
|
||||
// CHECKAGAIN retry. Update that object in place so the retry observes the accepted values.
|
||||
Object.assign(this.settings, extractObject(TweakValuesTemplate, conf));
|
||||
await this.services.setting.saveSettingData();
|
||||
if (!rebuildRequired) {
|
||||
// The failed replication has settled before mismatch resolution runs. Reinitialise the
|
||||
// chunk-generation managers now so hash and splitter changes take effect before retrying.
|
||||
await this.localDatabase.managers.reinitialise();
|
||||
}
|
||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type RemoteDBSettings, type TweakValues } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
type RemoteDBSettings,
|
||||
type TweakValues,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
|
||||
|
||||
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const askSelectStringDialogue = vi.fn(async () => undefined);
|
||||
const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise<string | undefined> => undefined);
|
||||
const applyPartial = vi.fn(async (_partial: Record<string, unknown>): Promise<void> => undefined);
|
||||
const reinitialise = vi.fn(async () => undefined);
|
||||
const core = {
|
||||
_services: {
|
||||
API: {
|
||||
@@ -15,6 +22,12 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
applyPartial,
|
||||
},
|
||||
},
|
||||
localDatabase: {
|
||||
managers: {
|
||||
reinitialise,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
@@ -26,6 +39,9 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
askSelectStringDialogue,
|
||||
},
|
||||
} as any;
|
||||
applyPartial.mockImplementation(async (partial: Record<string, unknown>) => {
|
||||
core.settings = { ...core.settings, ...partial };
|
||||
});
|
||||
|
||||
Object.defineProperty(core, "services", {
|
||||
get() {
|
||||
@@ -34,10 +50,35 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
});
|
||||
|
||||
const module = new ModuleResolvingMismatchedTweaks(core);
|
||||
return { module, core, askSelectStringDialogue };
|
||||
return { module, core, askSelectStringDialogue, applyPartial, reinitialise };
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it("should enable and auto-accept compatible mismatches when the preference is undefined", async () => {
|
||||
const { module, core, askSelectStringDialogue, applyPartial } = createModule({
|
||||
autoAcceptCompatibleTweak: undefined,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 100,
|
||||
});
|
||||
const initialSettings = core.settings;
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 200,
|
||||
} as Partial<TweakValues>;
|
||||
|
||||
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
|
||||
expect(conf).toEqual(preferred);
|
||||
expect(rebuild).toBe(false);
|
||||
expect(core.settings).toBe(initialSettings);
|
||||
expect(core.settings.autoAcceptCompatibleTweak).toBe(true);
|
||||
expect(core._services.setting.saveSettingData).toHaveBeenCalledTimes(1);
|
||||
expect(applyPartial).not.toHaveBeenCalled();
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
@@ -58,6 +99,28 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "neither side has a recorded time", currentModified: 0, preferredModified: 0 },
|
||||
{ label: "the recorded times are equal", currentModified: 200, preferredModified: 200 },
|
||||
])("should use the remote compatible value when $label", async ({ currentModified, preferredModified }) => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: currentModified,
|
||||
});
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: preferredModified,
|
||||
} as Partial<TweakValues>;
|
||||
|
||||
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
|
||||
expect(conf).toEqual(preferred);
|
||||
expect(rebuild).toBe(false);
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fallback to manual confirmation when mismatches are mixed on connect check", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
@@ -80,6 +143,24 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should fetch after applying a compatible remote setting when the user selects the rebuild option", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: false,
|
||||
hashAlg: "xxhash64",
|
||||
});
|
||||
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device, and fetch again");
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
} as TweakValues;
|
||||
|
||||
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
|
||||
expect(conf).toEqual(preferred);
|
||||
expect(rebuild).toBe(true);
|
||||
});
|
||||
|
||||
it("should auto-accept compatible mismatches on remote-config check using newer local tweakModified", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
@@ -105,4 +186,42 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
expect(result).toEqual({ result: false, requireFetch: false });
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should apply remote compatible settings in place and reinitialise managers before retrying", async () => {
|
||||
const { module, core, reinitialise } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 100,
|
||||
});
|
||||
const initialSettings = core.settings;
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 200,
|
||||
} as TweakValues;
|
||||
const calls: string[] = [];
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: vi.fn(async () => [preferred, false]),
|
||||
};
|
||||
core._services.setting.saveSettingData = vi.fn(async () => {
|
||||
calls.push("save");
|
||||
});
|
||||
core.replicator = {
|
||||
tweakSettingsMismatched: true,
|
||||
preferredTweakValue: preferred,
|
||||
setPreferredRemoteTweakSettings: vi.fn(async () => {
|
||||
calls.push("set-preferred");
|
||||
}),
|
||||
};
|
||||
reinitialise.mockImplementation(async () => {
|
||||
calls.push("reinitialise");
|
||||
});
|
||||
|
||||
const result = await module._askResolvingMismatchedTweaks();
|
||||
|
||||
expect(result).toBe("CHECKAGAIN");
|
||||
expect(core.settings).toBe(initialSettings);
|
||||
expect(core.settings.hashAlg).toBe("xxhash32");
|
||||
expect(calls).toEqual(["save", "reinitialise", "set-preferred"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,7 +206,8 @@ export class LiveSyncSetting extends Setting {
|
||||
const setValue = wrapMemo((value: boolean) => {
|
||||
toggle.setValue(opt?.invert ? !value : value);
|
||||
});
|
||||
this.invalidateValue = () => setValue(LiveSyncSetting.env.editingSettings[key] ?? false);
|
||||
this.invalidateValue = () =>
|
||||
setValue(LiveSyncSetting.env.editingSettings[key] ?? opt?.defaultToggleValue ?? false);
|
||||
this.invalidateValue();
|
||||
|
||||
toggle.onChange(async (value) => {
|
||||
|
||||
@@ -35,7 +35,9 @@ export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
clampMin: 10,
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
});
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak");
|
||||
new Setting(paneEl)
|
||||
.setClass("wizardHidden")
|
||||
.autoWireToggle("autoAcceptCompatibleTweak", { defaultToggleValue: true });
|
||||
// new Setting(paneEl)
|
||||
// .setClass("wizardHidden")
|
||||
// .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB })
|
||||
|
||||
@@ -75,6 +75,7 @@ export type AutoWireOption = {
|
||||
holdValue?: boolean;
|
||||
isPassword?: boolean;
|
||||
invert?: boolean;
|
||||
defaultToggleValue?: boolean;
|
||||
onUpdate?: OnUpdateFunc;
|
||||
obsolete?: boolean;
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
|
||||
|
||||
`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios.
|
||||
|
||||
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, and the Setup URI controls. It captures the representative dialogues on desktop and mobile, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that the remote-selection promise settles without an error. These UI-only checks do not apply a remote configuration or contact a remote service.
|
||||
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
|
||||
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobile
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import {
|
||||
captureObsidianDialogue,
|
||||
captureObsidianElement,
|
||||
captureObsidianPage,
|
||||
obsidianRemoteDebuggingPort,
|
||||
withObsidianPage,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const dialogRunStateKey = "__livesyncE2EDialogMount";
|
||||
const repairRunStateKey = "__livesyncE2ETroubleshootingRepair";
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000);
|
||||
|
||||
type DialogueMode = "desktop" | "mobile";
|
||||
@@ -20,6 +22,7 @@ type DialogueMode = "desktop" | "mobile";
|
||||
type DialogueRunState = {
|
||||
done: boolean;
|
||||
error?: string;
|
||||
expected?: unknown;
|
||||
kind: string;
|
||||
result?: unknown;
|
||||
};
|
||||
@@ -27,18 +30,39 @@ type DialogueRunState = {
|
||||
type SetupManagerHandle = {
|
||||
constructor: { name: string };
|
||||
onSelectServer?: (settings: unknown, remoteType: string) => Promise<unknown>;
|
||||
_askUseRemoteConfiguration?: (settings: unknown, preferred: unknown) => Promise<unknown>;
|
||||
_checkAndAskResolvingMismatchedTweaks?: (preferred: unknown) => Promise<unknown>;
|
||||
__addLog?: (message: string) => void;
|
||||
};
|
||||
|
||||
type LiveSyncTestPlugin = {
|
||||
core: {
|
||||
fileHandler: {
|
||||
createAllChunks(force: boolean): Promise<unknown>;
|
||||
};
|
||||
modules: SetupManagerHandle[];
|
||||
settings: unknown;
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
|
||||
type ObsidianVaultFile = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
|
||||
setting?: ObsidianSettingsController;
|
||||
vault?: {
|
||||
delete(file: ObsidianVaultFile, force: boolean): Promise<void>;
|
||||
getFiles(): ObsidianVaultFile[];
|
||||
read(file: ObsidianVaultFile): Promise<string>;
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
@@ -78,7 +102,62 @@ async function openSetupUriDialogue(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertDialogueRunCompleted(): Promise<void> {
|
||||
async function openConfigurationMismatchDialogue(
|
||||
kind: "connected" | "connected-rebuild-recommended" | "remote-configuration"
|
||||
): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate(
|
||||
({ stateKey, kind }) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
const resolver = plugin.core.modules.find(
|
||||
(module) => module.constructor.name === "ModuleResolvingMismatchedTweaks"
|
||||
);
|
||||
if (resolver === undefined) throw new Error("Could not find ModuleResolvingMismatchedTweaks");
|
||||
if (kind === "connected-rebuild-recommended") {
|
||||
plugin.core.settings.autoAcceptCompatibleTweak = false;
|
||||
}
|
||||
|
||||
const preferred =
|
||||
kind === "connected-rebuild-recommended"
|
||||
? {
|
||||
...plugin.core.settings,
|
||||
hashAlg: plugin.core.settings.hashAlg === "xxhash32" ? "xxhash64" : "xxhash32",
|
||||
}
|
||||
: {
|
||||
...plugin.core.settings,
|
||||
enableCompression: !Boolean(plugin.core.settings.enableCompression),
|
||||
};
|
||||
const state: DialogueRunState = {
|
||||
kind: `configuration-mismatch-${kind}`,
|
||||
done: false,
|
||||
expected: preferred,
|
||||
};
|
||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||
const operation =
|
||||
kind === "remote-configuration"
|
||||
? resolver._askUseRemoteConfiguration?.(plugin.core.settings, preferred)
|
||||
: resolver._checkAndAskResolvingMismatchedTweaks?.(preferred);
|
||||
if (operation === undefined) {
|
||||
throw new Error(`The configuration mismatch resolver does not support ${kind}.`);
|
||||
}
|
||||
void operation.then(
|
||||
(result) => {
|
||||
state.result = result;
|
||||
state.done = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
state.done = true;
|
||||
}
|
||||
);
|
||||
},
|
||||
{ stateKey: dialogRunStateKey, kind }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function assertDialogueRunCompleted(): Promise<DialogueRunState> {
|
||||
const state = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.waitForFunction(
|
||||
(stateKey) =>
|
||||
@@ -92,11 +171,12 @@ async function assertDialogueRunCompleted(): Promise<void> {
|
||||
);
|
||||
});
|
||||
if (!state) {
|
||||
throw new Error("The remote selection dialogue did not record its completion state.");
|
||||
throw new Error("The mounted dialogue did not record its completion state.");
|
||||
}
|
||||
if (state.error) {
|
||||
throw new Error(`The remote selection dialogue failed: ${state.error}`);
|
||||
throw new Error(`The mounted dialogue failed: ${state.error}`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function verifyRemoteSizeNoticeAndDialogue(): Promise<{
|
||||
@@ -347,6 +427,442 @@ async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> {
|
||||
return screenshotPath;
|
||||
}
|
||||
|
||||
async function verifyCompatibleMismatchAutoAdjustment(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
const resolver = plugin.core.modules.find(
|
||||
(module) => module.constructor.name === "ModuleResolvingMismatchedTweaks"
|
||||
);
|
||||
if (typeof resolver?._checkAndAskResolvingMismatchedTweaks !== "function") {
|
||||
throw new Error("Could not find the configuration mismatch resolver");
|
||||
}
|
||||
plugin.core.settings.autoAcceptCompatibleTweak = undefined;
|
||||
const currentModified =
|
||||
typeof plugin.core.settings.tweakModified === "number" ? plugin.core.settings.tweakModified : 0;
|
||||
const preferred = {
|
||||
...plugin.core.settings,
|
||||
hashAlg: plugin.core.settings.hashAlg === "xxhash32" ? "xxhash64" : "xxhash32",
|
||||
tweakModified: currentModified + 1,
|
||||
};
|
||||
const state: DialogueRunState = {
|
||||
kind: "configuration-mismatch-compatible-auto-adjustment",
|
||||
done: false,
|
||||
expected: preferred,
|
||||
};
|
||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||
void resolver._checkAndAskResolvingMismatchedTweaks(preferred).then(
|
||||
(result) => {
|
||||
state.result = result;
|
||||
state.done = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
state.done = true;
|
||||
}
|
||||
);
|
||||
}, dialogRunStateKey);
|
||||
});
|
||||
|
||||
const state = await assertDialogueRunCompleted();
|
||||
if (!Array.isArray(state.result) || state.result.length !== 2) {
|
||||
throw new Error("The compatible mismatch did not return its settings and rebuild decision.");
|
||||
}
|
||||
const [appliedSettings, shouldRebuild] = state.result;
|
||||
const expectedSettings = state.expected;
|
||||
if (
|
||||
typeof appliedSettings !== "object" ||
|
||||
appliedSettings === null ||
|
||||
typeof expectedSettings !== "object" ||
|
||||
expectedSettings === null ||
|
||||
!("hashAlg" in appliedSettings) ||
|
||||
!("hashAlg" in expectedSettings) ||
|
||||
appliedSettings.hashAlg !== expectedSettings.hashAlg ||
|
||||
shouldRebuild !== false
|
||||
) {
|
||||
throw new Error("The compatible mismatch was not adjusted to the newer setting without a rebuild.");
|
||||
}
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const autoAcceptEnabled = await page.evaluate(() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.settings.autoAcceptCompatibleTweak;
|
||||
});
|
||||
if (autoAcceptEnabled !== true) {
|
||||
throw new Error("Compatible mismatch auto-adjustment was not persisted as the default.");
|
||||
}
|
||||
for (const title of ["Auto-Accept Available", "Configuration Mismatch Detected"]) {
|
||||
const dialogue = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: title }),
|
||||
});
|
||||
if ((await dialogue.count()) !== 0) {
|
||||
throw new Error(`Compatible mismatch auto-adjustment unexpectedly opened '${title}'.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyCompatibleAlignmentSettingDefault(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const persistedValue = await page.evaluate(() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
return plugin.core.settings.autoAcceptCompatibleTweak;
|
||||
});
|
||||
if (persistedValue !== undefined) {
|
||||
throw new Error(
|
||||
`The default-display fixture expected an undefined preference, received ${persistedValue}.`
|
||||
);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Advanced"]').click({ timeout: uiTimeoutMs });
|
||||
const settingItem = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Auto-accept compatible tweak mismatches", { exact: true }),
|
||||
});
|
||||
await settingItem.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const toggle = settingItem.locator(".checkbox-container");
|
||||
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.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyConfigurationMismatchDialogues(): Promise<{ general: string; fetch: string }> {
|
||||
await verifyCompatibleMismatchAutoAdjustment();
|
||||
await openConfigurationMismatchDialogue("remote-configuration");
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Use Remote Configuration" }),
|
||||
});
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal
|
||||
.getByRole("button", { name: "Use configured settings", exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.getByRole("button", { name: "Dismiss", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
await assertDialogueRunCompleted();
|
||||
|
||||
await openConfigurationMismatchDialogue("connected");
|
||||
const generalScreenshotPath = await captureObsidianElement(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"troubleshooting-configuration-mismatch-dialogue.png",
|
||||
async (page) => {
|
||||
const container = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }),
|
||||
});
|
||||
const modal = container.locator(".modal").last();
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
for (const action of ["Apply settings to this device", "Update remote database settings"]) {
|
||||
await modal
|
||||
.getByRole("button", { name: action, exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
await modal.getByRole("button", { name: /Dismiss$/u }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
for (const retiredAction of ["Use configured", "Update with mine"]) {
|
||||
if ((await modal.getByRole("button", { name: retiredAction, exact: true }).count()) !== 0) {
|
||||
throw new Error(`The mismatch dialogue still exposes the retired action '${retiredAction}'.`);
|
||||
}
|
||||
}
|
||||
const actions = modal.locator(".setting-item-control").last();
|
||||
const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection);
|
||||
if (flexDirection !== "column") {
|
||||
throw new Error(`Expected vertically stacked mismatch actions, received ${flexDirection}.`);
|
||||
}
|
||||
return modal;
|
||||
}
|
||||
);
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }),
|
||||
});
|
||||
await modal.getByRole("button", { name: /Dismiss$/u }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
await assertDialogueRunCompleted();
|
||||
|
||||
await openConfigurationMismatchDialogue("connected-rebuild-recommended");
|
||||
const fetchScreenshotPath = await captureObsidianElement(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"troubleshooting-configuration-mismatch-fetch-dialogue.png",
|
||||
async (page) => {
|
||||
const container = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }),
|
||||
});
|
||||
const modal = container.locator(".modal").last();
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal
|
||||
.getByRole("button", { name: "Apply settings to this device, and fetch again", exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
return modal;
|
||||
}
|
||||
);
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }),
|
||||
});
|
||||
await modal
|
||||
.getByRole("button", { name: "Apply settings to this device, and fetch again", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
const fetchResult = await assertDialogueRunCompleted();
|
||||
if (!Array.isArray(fetchResult.result) || fetchResult.result.length !== 2) {
|
||||
throw new Error("The configuration-mismatch Fetch action did not return its settings and Fetch decision.");
|
||||
}
|
||||
const [appliedSettings, shouldFetch] = fetchResult.result;
|
||||
const expectedSettings = fetchResult.expected;
|
||||
if (
|
||||
typeof appliedSettings !== "object" ||
|
||||
appliedSettings === null ||
|
||||
typeof expectedSettings !== "object" ||
|
||||
expectedSettings === null ||
|
||||
!("hashAlg" in appliedSettings) ||
|
||||
!("hashAlg" in expectedSettings) ||
|
||||
appliedSettings.hashAlg !== expectedSettings.hashAlg ||
|
||||
shouldFetch !== true
|
||||
) {
|
||||
throw new Error("The configuration-mismatch Fetch action did not apply the remote setting before Fetch.");
|
||||
}
|
||||
|
||||
return { general: generalScreenshotPath, fetch: fetchScreenshotPath };
|
||||
}
|
||||
|
||||
async function executeRegisteredCommand(commandId: string): Promise<void> {
|
||||
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
return await page.evaluate(
|
||||
(id) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(id) === true,
|
||||
commandId
|
||||
);
|
||||
});
|
||||
if (!opened) {
|
||||
throw new Error(`The command was not registered or could not be executed: ${commandId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyLogAndReportSurfaces(): Promise<{ log: string; report: string }> {
|
||||
await executeRegisteredCommand("obsidian-livesync:view-log");
|
||||
const logScreenshot = await captureObsidianElement(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"troubleshooting-show-log.png",
|
||||
async (page) => {
|
||||
const logPane = page.locator(".logpane");
|
||||
await logPane.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
for (const label of ["Wrap", "Auto scroll", "Pause"]) {
|
||||
await logPane.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
await logPane.getByRole("button", { name: "Close", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
return logPane;
|
||||
}
|
||||
);
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const logPane = page.locator(".logpane");
|
||||
await logPane.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await logPane.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
await executeRegisteredCommand("obsidian-livesync:dump-debug-info");
|
||||
const reportScreenshot = await captureObsidianElement(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"troubleshooting-full-report.png",
|
||||
async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
hasText: "Your Debug info is ready to be copied",
|
||||
});
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const report = await modal.locator("textarea").inputValue({ timeout: uiTimeoutMs });
|
||||
if (!report.includes("# ---- Debug Info Dump ----")) {
|
||||
throw new Error("The full-report dialogue did not contain the generated debug report.");
|
||||
}
|
||||
await modal.getByRole("button", { name: "OK", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
return modal.locator(".modal").last();
|
||||
}
|
||||
);
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
hasText: "Your Debug info is ready to be copied",
|
||||
});
|
||||
await modal.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
return { log: logScreenshot, report: reportScreenshot };
|
||||
}
|
||||
|
||||
async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
const screenshotPath = await captureObsidianElement(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"troubleshooting-hatch.png",
|
||||
async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
|
||||
for (const label of [
|
||||
"Write logs into the file",
|
||||
"Recreate missing chunks for all files",
|
||||
"Verify and repair all files",
|
||||
]) {
|
||||
await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
await liveSyncSettings.getByRole("button", { name: "Recreate all", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await liveSyncSettings
|
||||
.locator(".setting-item-name", { hasText: "Recreate missing chunks for all files" })
|
||||
.scrollIntoViewIfNeeded();
|
||||
return liveSyncSettings;
|
||||
}
|
||||
);
|
||||
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
const logSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Write logs into the file", { exact: true }),
|
||||
});
|
||||
await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.settings.writeLogToTheFile === true;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
|
||||
const persistentLogMarker = "E2E persistent troubleshooting log";
|
||||
await page.evaluate((marker) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
const module = plugin.core.modules.find((candidate) => candidate.constructor.name === "ModuleLog");
|
||||
if (typeof module?.__addLog !== "function") throw new Error("Could not find ModuleLog");
|
||||
module.__addLog(marker);
|
||||
}, persistentLogMarker);
|
||||
await page.waitForFunction(
|
||||
async (marker) => {
|
||||
const vault = (globalThis as ObsidianTestGlobal).app?.vault;
|
||||
if (vault === undefined) return false;
|
||||
const logFile = vault.getFiles().find((file) => file.path.startsWith("livesync_log_"));
|
||||
if (logFile === undefined) return false;
|
||||
return (await vault.read(logFile)).includes(marker);
|
||||
},
|
||||
persistentLogMarker,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
|
||||
// Saving a toggle refreshes the settings pane. Resolve the visible control again so the
|
||||
// second action does not target the detached pre-save element.
|
||||
const refreshedLogToggle = page
|
||||
.locator(".sls-setting:visible .setting-item:visible")
|
||||
.filter({
|
||||
has: page.getByText("Write logs into the file", { exact: true }),
|
||||
})
|
||||
.locator(".checkbox-container:visible")
|
||||
.last();
|
||||
await refreshedLogToggle.click({ timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.settings.writeLogToTheFile === false;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const vault = (globalThis as ObsidianTestGlobal).app?.vault;
|
||||
if (vault === undefined) throw new Error("Obsidian Vault is unavailable");
|
||||
const logFile = vault.getFiles().find((file) => file.path.startsWith("livesync_log_"));
|
||||
if (logFile === undefined) throw new Error("The persistent troubleshooting log was not created");
|
||||
await vault.delete(logFile, true);
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!(globalThis as ObsidianTestGlobal).app?.vault
|
||||
?.getFiles()
|
||||
.some((file) => file.path.startsWith("livesync_log_")),
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
|
||||
await page.evaluate((stateKey) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
const original = plugin.core.fileHandler.createAllChunks.bind(plugin.core.fileHandler);
|
||||
const state: DialogueRunState = { kind: "recreate-missing-chunks", done: false };
|
||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||
plugin.core.fileHandler.createAllChunks = async (force) => {
|
||||
try {
|
||||
state.result = await original(force);
|
||||
} catch (error) {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
state.done = true;
|
||||
plugin.core.fileHandler.createAllChunks = original;
|
||||
}
|
||||
};
|
||||
}, repairRunStateKey);
|
||||
await liveSyncSettings.getByRole("button", { name: "Recreate all", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]?.done === true,
|
||||
repairRunStateKey,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
const repairState = await page.evaluate(
|
||||
(stateKey) => (globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey],
|
||||
repairRunStateKey
|
||||
);
|
||||
if (repairState?.error) {
|
||||
throw new Error(`Recreate missing chunks failed: ${repairState.error}`);
|
||||
}
|
||||
|
||||
await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await page
|
||||
.locator(".notice")
|
||||
.filter({ hasText: /^done$/u })
|
||||
.waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
});
|
||||
|
||||
return screenshotPath;
|
||||
}
|
||||
|
||||
async function verifyMobileStartupReviews(): Promise<{ compatibilityReview: string; remoteSizeReview: string }> {
|
||||
const compatibilityReviewScreenshot = await captureObsidianDialogue(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
@@ -430,13 +946,14 @@ async function main(): Promise<void> {
|
||||
dbName: "dialog-mounts-ui-only",
|
||||
},
|
||||
{
|
||||
notifyThresholdOfRemoteStorageSize: -1,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnEditorSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncAfterMerge: false,
|
||||
periodicReplication: false,
|
||||
notifyThresholdOfRemoteStorageSize: -1,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnEditorSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncAfterMerge: false,
|
||||
periodicReplication: false,
|
||||
useAdvancedMode: true,
|
||||
}
|
||||
),
|
||||
});
|
||||
@@ -480,6 +997,20 @@ async function main(): Promise<void> {
|
||||
);
|
||||
const setupUriScreenshot = await verifySetupUriDialogue("desktop");
|
||||
console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`);
|
||||
await verifyCompatibleAlignmentSettingDefault();
|
||||
console.log("The undefined compatible-setting preference is displayed with its effective enabled default.");
|
||||
const mismatchScreenshots = await verifyConfigurationMismatchDialogues();
|
||||
console.log(
|
||||
`A mismatch limited to compatible chunk settings was adjusted without a dialogue, current manual mismatch actions mounted successfully, and the Fetch action applied the remote setting before scheduling Fetch. Screenshots: ${mismatchScreenshots.general}, ${mismatchScreenshots.fetch}`
|
||||
);
|
||||
const troubleshootingScreenshots = await verifyLogAndReportSurfaces();
|
||||
console.log(
|
||||
`Show log and the generated full-report dialogue were reached through their registered commands. Screenshots: ${troubleshootingScreenshots.log}, ${troubleshootingScreenshots.report}`
|
||||
);
|
||||
const hatchScreenshot = await verifyHatchSurfacesAndSafeActions();
|
||||
console.log(
|
||||
`Hatch repair controls were reachable, safe empty-fixture runs completed, and persistent logging was enabled, verified, disabled, and removed. Screenshot: ${hatchScreenshot}`
|
||||
);
|
||||
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
|
||||
try {
|
||||
|
||||
@@ -19,10 +19,13 @@ Earlier releases remain available in the [0.25 release history](https://github.c
|
||||
- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits.
|
||||
- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`.
|
||||
- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation.
|
||||
- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred.
|
||||
- Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings.
|
||||
- Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart.
|
||||
|
||||
### Testing
|
||||
|
||||
|
||||
Reference in New Issue
Block a user