mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-01 09:21:23 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 809caffed9 |
@@ -62,7 +62,6 @@ jobs:
|
||||
VERSION: ${{ inputs.version }}
|
||||
EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }}
|
||||
PRERELEASE: ${{ inputs.prerelease }}
|
||||
PUBLISH_CLI: ${{ inputs.publish_cli }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ACTUAL_HEAD_SHA="$(git rev-parse HEAD)"
|
||||
@@ -74,10 +73,6 @@ jobs:
|
||||
echo "Version ${VERSION} is a pre-release version, but prerelease was not enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then
|
||||
echo "A stable version staged as a pre-release must use publish_cli=false so that the CLI latest and major-minor image tags do not advance before BRAT validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
node utils/release-notes.mjs validate "${VERSION}"
|
||||
|
||||
- name: Ensure and push release tags
|
||||
@@ -101,15 +96,8 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
PRERELEASE: ${{ inputs.prerelease }}
|
||||
PUBLISH_CLI: ${{ inputs.publish_cli }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${PUBLISH_CLI}" == "true" ]]; then
|
||||
gh workflow run cli-docker.yml \
|
||||
--ref "${VERSION}-cli" \
|
||||
--field dry_run=false \
|
||||
--field force=false
|
||||
fi
|
||||
gh workflow run release.yml \
|
||||
--ref "${VERSION}" \
|
||||
--field tag="${VERSION}" \
|
||||
@@ -125,21 +113,15 @@ jobs:
|
||||
{
|
||||
echo "Ensured the plug-in tag \`${VERSION}\` points to the reviewed release commit."
|
||||
if [[ "${PUBLISH_CLI}" == "true" ]]; then
|
||||
echo "The CLI tag \`${VERSION}-cli\` was also created, and finalisation explicitly dispatched the CLI container workflow."
|
||||
echo "The CLI tag \`${VERSION}-cli\` was also created; its tag event starts the container workflow."
|
||||
else
|
||||
echo "CLI publication was omitted."
|
||||
fi
|
||||
echo ""
|
||||
echo "Dispatched the plug-in release workflow for \`${VERSION}\`. After approval for the release environment, it creates a draft GitHub Release."
|
||||
echo ""
|
||||
if [[ "${VERSION}" == *-* ]]; then
|
||||
echo "Publish the draft as a pre-release without replacing the latest stable release."
|
||||
echo "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action."
|
||||
elif [[ "${PRERELEASE}" == "true" ]]; then
|
||||
echo "Publish the draft initially as a pre-release without replacing the latest stable release."
|
||||
echo "After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
|
||||
echo "Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release."
|
||||
echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate."
|
||||
if [[ "${PRERELEASE}" == "true" ]]; then
|
||||
echo "Publish the draft as a pre-release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
|
||||
else
|
||||
echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
|
||||
fi
|
||||
|
||||
+3
-4
@@ -1,12 +1,11 @@
|
||||
import { writeFileSync } from "fs";
|
||||
import { allMessages } from "../src/common/messages/combinedMessages.dev.ts";
|
||||
|
||||
const { writeFileSync } = process.getBuiltinModule("node:fs");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
import path from "path";
|
||||
const __dirname = import.meta.dirname;
|
||||
const currentPath = __dirname;
|
||||
const outDir = path.resolve(currentPath, "../src/common/messages/combinedMessages.prod.ts");
|
||||
|
||||
process.stdout.write(`Writing to ${outDir}\n`);
|
||||
console.log(`Writing to ${outDir}`);
|
||||
writeFileSync(
|
||||
outDir,
|
||||
`export const allMessages: Readonly<Record<string, Readonly<Record<string, string>>>> = ${JSON.stringify(allMessages, null, 4)};`
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join, resolve } from "path";
|
||||
import { glob } from "tinyglobby";
|
||||
import { parse } from "yaml";
|
||||
import { objectToDotted } from "./messagelib.ts";
|
||||
|
||||
const fsPromises = process.getBuiltinModule("node:fs/promises");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const __dirname = import.meta.dirname;
|
||||
const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/"));
|
||||
const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/"));
|
||||
const files = (await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir })).sort();
|
||||
|
||||
function flattenMessages(src: unknown) {
|
||||
function flattenMessages(src: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(objectToDotted(src))
|
||||
.map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value] as const)
|
||||
@@ -20,14 +20,9 @@ function flattenMessages(src: unknown) {
|
||||
const localeData = new Map<string, Record<string, string>>();
|
||||
for (const file of files) {
|
||||
const segments = file.split(/[/\\]/);
|
||||
const localeFilename = segments[segments.length - 1];
|
||||
if (localeFilename === undefined) {
|
||||
throw new Error(`Could not determine the locale name for ${file}`);
|
||||
}
|
||||
const locale = localeFilename.replace(/\.yaml$/, "");
|
||||
const content = await fsPromises.readFile(file, "utf-8");
|
||||
const parsed: unknown = parse(content);
|
||||
localeData.set(locale, flattenMessages(parsed ?? {}));
|
||||
const locale = segments[segments.length - 1]!.replace(/\.yaml$/, "");
|
||||
const content = await readFile(file, "utf-8");
|
||||
localeData.set(locale, flattenMessages(parse(content) ?? {}));
|
||||
}
|
||||
|
||||
const baseLocale = "en";
|
||||
@@ -60,4 +55,4 @@ const report = Object.fromEntries(
|
||||
})
|
||||
);
|
||||
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "../src/common/rosetta";
|
||||
import { allMessages } from "../src/common/messages/combinedMessages.dev.ts";
|
||||
|
||||
const { writeFileSync } = process.getBuiltinModule("node:fs");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
import path from "path";
|
||||
const thisFileDir = __dirname;
|
||||
const outDir = path.join(thisFileDir, "i18n");
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
import { allMessages } from "../src/common/messages/combinedMessages.prod.ts";
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
const { writeFileSync } = process.getBuiltinModule("node:fs");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
import path from "path";
|
||||
const thisFileDir = __dirname;
|
||||
const outDir = path.resolve(thisFileDir, "../src/common/messagesJson");
|
||||
|
||||
const out = {} as Record<string, { [key: string]: string | undefined }>;
|
||||
|
||||
for (const [key, value] of Object.entries(allMessages)) {
|
||||
for (const [lang, langValue] of Object.entries(value)) {
|
||||
//@ts-ignore
|
||||
for (const [lang, langValue] of Object.entries(allMessages[key])) {
|
||||
if (!out[lang]) out[lang] = {};
|
||||
out[lang][key] = langValue;
|
||||
if (lang in value) {
|
||||
out[lang][key] = langValue as string;
|
||||
} else {
|
||||
if (lang === "def") {
|
||||
out[lang][key] = key;
|
||||
} else {
|
||||
out[lang][key] = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const fsPromises = process.getBuiltinModule("node:fs/promises");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const url = process.getBuiltinModule("node:url");
|
||||
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";
|
||||
@@ -20,7 +20,7 @@ const messageCataloguePath = "src/common/messagesJson/en.json";
|
||||
const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu;
|
||||
|
||||
function repositoryRootFromThisFile(): string {
|
||||
return path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
}
|
||||
|
||||
function normaliseReferenceTarget(rawTarget: string): string {
|
||||
@@ -48,14 +48,14 @@ async function inspectLocalReferences(
|
||||
const [pathPart] = target.split("#", 1);
|
||||
if (!pathPart) continue;
|
||||
checked++;
|
||||
const referencedPath = path.resolve(repositoryRoot, path.dirname(documentPath), pathPart);
|
||||
const referencedPath = resolve(repositoryRoot, dirname(documentPath), pathPart);
|
||||
try {
|
||||
await fsPromises.access(referencedPath);
|
||||
await access(referencedPath);
|
||||
} catch {
|
||||
errors.push({
|
||||
check: "local-reference",
|
||||
file: documentPath,
|
||||
detail: `Missing local reference: ${path.relative(repositoryRoot, referencedPath)}`,
|
||||
detail: `Missing local reference: ${relative(repositoryRoot, referencedPath)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -68,13 +68,14 @@ export async function inspectTroubleshootingDocs(
|
||||
const errors: InspectionError[] = [];
|
||||
const documents = new Map<string, string>();
|
||||
for (const guidePath of guidePaths) {
|
||||
documents.set(guidePath, await fsPromises.readFile(path.resolve(repositoryRoot, guidePath), "utf8"));
|
||||
documents.set(guidePath, await readFile(resolve(repositoryRoot, guidePath), "utf8"));
|
||||
}
|
||||
|
||||
const troubleshooting = documents.get("docs/troubleshooting.md")!;
|
||||
const catalogue = JSON.parse(
|
||||
await fsPromises.readFile(path.resolve(repositoryRoot, messageCataloguePath), "utf8")
|
||||
) as Record<string, string>;
|
||||
const catalogue = JSON.parse(await readFile(resolve(repositoryRoot, messageCataloguePath), "utf8")) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const requiredMessageKeys = [
|
||||
"TweakMismatchResolve.Action.UseConfigured",
|
||||
"TweakMismatchResolve.Action.UseMine",
|
||||
@@ -131,7 +132,7 @@ async function runCli(): Promise<void> {
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? url.pathToFileURL(path.resolve(process.argv[1])).href : undefined;
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined;
|
||||
if (invokedPath === import.meta.url) {
|
||||
await runCli();
|
||||
}
|
||||
|
||||
+10
-14
@@ -1,31 +1,27 @@
|
||||
// Convert Application convenient Message Resources (JSON) to Human-Editable format (YAML)
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join, resolve } from "path";
|
||||
import { stringify } from "yaml";
|
||||
import { glob } from "tinyglobby";
|
||||
import { dottedToObject } from "./messagelib";
|
||||
|
||||
const fsPromises = process.getBuiltinModule("node:fs/promises");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesJson/"));
|
||||
process.stdout.write(`Target directory: ${targetDir}\n`);
|
||||
const targetDir = resolve(join(__dirname, "../src/common/messagesJson/"));
|
||||
console.log(`Target directory: ${targetDir}`);
|
||||
const files = await glob(`*.json`, { expandDirectories: false, absolute: true, cwd: targetDir });
|
||||
for (const file of files) {
|
||||
const filePath = path.resolve(file);
|
||||
process.stdout.write(`Processing file: ${filePath}\n`);
|
||||
const content = await fsPromises.readFile(filePath, "utf-8");
|
||||
const jsonDataSrc: unknown = JSON.parse(content);
|
||||
if (typeof jsonDataSrc !== "object" || jsonDataSrc === null || Array.isArray(jsonDataSrc)) {
|
||||
throw new TypeError(`Expected ${filePath} to contain a JSON object`);
|
||||
}
|
||||
const filePath = resolve(file);
|
||||
console.log(`Processing file: ${filePath}`);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const jsonDataSrc = JSON.parse(content);
|
||||
const jsonDataD2 = Object.fromEntries(
|
||||
Object.entries(jsonDataSrc).sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
|
||||
);
|
||||
const jsonData = dottedToObject(jsonDataD2);
|
||||
const yamlData = stringify(jsonData, { indent: 2 });
|
||||
const yamlFilePath = filePath.replace(/\.json$/, ".yaml").replace("Json", "YAML");
|
||||
await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8");
|
||||
process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`);
|
||||
await writeFile(yamlFilePath, yamlData, "utf-8");
|
||||
console.log(`Converted ${filePath} to ${yamlFilePath}`);
|
||||
}
|
||||
|
||||
// console.dir(files, { depth: 0 });
|
||||
|
||||
+36
-47
@@ -1,49 +1,38 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function objectToDotted(obj: unknown, prefix = ""): Record<string, unknown> {
|
||||
if (!isRecord(obj)) {
|
||||
throw new TypeError("Expected a message catalogue object");
|
||||
}
|
||||
const flattened: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (isRecord(value)) {
|
||||
Object.assign(flattened, objectToDotted(value, newKey));
|
||||
} else {
|
||||
flattened[newKey] = value;
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
export function dottedToObject(obj: unknown): Record<string, unknown> {
|
||||
if (!isRecord(obj)) {
|
||||
throw new TypeError("Expected a dotted message catalogue object");
|
||||
}
|
||||
const nestedResult: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (key.includes(" ")) {
|
||||
nestedResult[key] = value;
|
||||
continue;
|
||||
}
|
||||
const keys = key.split(".");
|
||||
let nested = nestedResult;
|
||||
for (const [index, currentKey] of keys.entries()) {
|
||||
if (index === keys.length - 1) {
|
||||
nested[currentKey] = value;
|
||||
continue;
|
||||
export function objectToDotted(obj: any, prefix = ""): Record<string, any> {
|
||||
return Object.entries(obj).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(acc, objectToDotted(value, newKey));
|
||||
} else {
|
||||
acc[newKey] = value;
|
||||
}
|
||||
const currentValue = nested[currentKey];
|
||||
if (isRecord(currentValue)) {
|
||||
nested = currentValue;
|
||||
continue;
|
||||
}
|
||||
const replacement = currentValue === undefined || currentValue === null ? {} : { _value: currentValue };
|
||||
nested[currentKey] = replacement;
|
||||
nested = replacement;
|
||||
}
|
||||
}
|
||||
return nestedResult;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
}
|
||||
export function dottedToObject(obj: Record<string, any>): Record<string, any> {
|
||||
return Object.entries(obj).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (key.includes(" ")) {
|
||||
// Return as is.
|
||||
return { ...acc, [key]: value }; // Skip keys with spaces
|
||||
}
|
||||
const keys = key.split(".");
|
||||
keys.reduce((nestedAcc, currKey, index) => {
|
||||
if (currKey in nestedAcc && typeof nestedAcc[currKey] !== "object") {
|
||||
nestedAcc[currKey] = { _value: nestedAcc[currKey] }; // Convert to object if not already
|
||||
}
|
||||
if (index === keys.length - 1) {
|
||||
nestedAcc[currKey] = value;
|
||||
} else {
|
||||
nestedAcc[currKey] = nestedAcc[currKey] || {};
|
||||
}
|
||||
return nestedAcc[currKey];
|
||||
}, acc);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dottedToObject, objectToDotted } from "./messagelib";
|
||||
|
||||
describe("message catalogue conversion", () => {
|
||||
it("flattens nested objects while preserving leaf values", () => {
|
||||
expect(
|
||||
objectToDotted({
|
||||
dialogue: {
|
||||
title: "Title",
|
||||
options: ["first", "second"],
|
||||
},
|
||||
"literal key": "Literal",
|
||||
})
|
||||
).toEqual({
|
||||
"dialogue.title": "Title",
|
||||
"dialogue.options": ["first", "second"],
|
||||
"literal key": "Literal",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an existing leaf under _value when a dotted child follows it", () => {
|
||||
expect(
|
||||
dottedToObject({
|
||||
section: "Base value",
|
||||
"section.child": "Child value",
|
||||
"literal key": "Literal",
|
||||
})
|
||||
).toEqual({
|
||||
section: {
|
||||
_value: "Base value",
|
||||
child: "Child value",
|
||||
},
|
||||
"literal key": "Literal",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-object catalogue roots", () => {
|
||||
expect(() => objectToDotted("not an object")).toThrow("Expected a message catalogue object");
|
||||
expect(() => dottedToObject(["not", "an", "object"])).toThrow("Expected a dotted message catalogue object");
|
||||
});
|
||||
});
|
||||
+10
-11
@@ -1,30 +1,29 @@
|
||||
// Convert Human-Editable format (YAML) to Application convenient Message Resources (JSON)
|
||||
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join, resolve } from "path";
|
||||
import { parse } from "yaml";
|
||||
import { glob } from "tinyglobby";
|
||||
import { objectToDotted } from "./messagelib";
|
||||
|
||||
const fsPromises = process.getBuiltinModule("node:fs/promises");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/"));
|
||||
process.stdout.write(`Target directory: ${targetDir}\n`);
|
||||
const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/"));
|
||||
console.log(`Target directory: ${targetDir}`);
|
||||
const files = await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir });
|
||||
for (const file of files) {
|
||||
const filePath = path.resolve(file);
|
||||
const content = await fsPromises.readFile(filePath, "utf-8");
|
||||
const jsonDataSrc: unknown = parse(content);
|
||||
const filePath = resolve(file);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const jsonDataSrc = parse(content);
|
||||
const jsonDataD2 = objectToDotted(jsonDataSrc);
|
||||
const jsonData = Object.fromEntries(
|
||||
Object.entries(jsonDataD2)
|
||||
.map(([key, value]): [string, unknown] => [key.endsWith("._value") ? key.slice(0, -7) : key, value])
|
||||
.map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value])
|
||||
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
|
||||
);
|
||||
const yamlData = JSON.stringify(jsonData, null, 4) + "\n";
|
||||
const yamlFilePath = filePath.replace(/\.yaml$/, ".json").replace("YAML", "Json");
|
||||
await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8");
|
||||
process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`);
|
||||
await writeFile(yamlFilePath, yamlData, "utf-8");
|
||||
console.log(`Converted ${filePath} to ${yamlFilePath}`);
|
||||
}
|
||||
|
||||
// console.dir(files, { depth: 0 });
|
||||
|
||||
@@ -244,10 +244,7 @@ export class ModuleExample extends AbstractObsidianModule {
|
||||
- Use SemVer beta identifiers such as `1.0.0-beta.0` for immutable integration previews. Increment the beta number when a published preview needs a correction. Reserve `1.0.0-rc.0` for the first feature- and contract-frozen release candidate. Historical `-patchedN` releases remain unchanged in the release history.
|
||||
- Publish a pre-release from an immutable reviewed tag, mark its GitHub Release as a pre-release, and do not replace the latest stable release.
|
||||
- A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag.
|
||||
- Keep a hyphenated pre-release's release pull request in draft and unmerged after BRAT validation. Reconcile the published version's metadata into its base branch through a reviewed metadata-only commit, then close the release pull request only through a separate maintainer action.
|
||||
- Stage a stable version for BRAT by publishing its exact `x.y.z` tag initially as a GitHub pre-release with `prerelease=true` and `publish_cli=false`. The stable manifest version would otherwise make the CLI workflow advance `latest` and the major-minor image tag before validation.
|
||||
- After a staged stable version passes BRAT validation, merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. Promote the GitHub Release only after the default branch contains the exact stable metadata; publish stable CLI tags through a separate maintainer gate.
|
||||
- If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version.
|
||||
- Keep the release pull request in draft until the exact published plug-in has passed BRAT validation. If validation fails, prepare the next pre-release version rather than moving the existing tag.
|
||||
|
||||
## Release Notes
|
||||
|
||||
@@ -265,17 +262,15 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
|
||||
|
||||
- Run the `Prepare Release PR` workflow with the target version and selected base branch. It creates the release branch, updates versions, confirms that Commonlib is locked to an immutable package version, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR. The base branch may already select the target development version; the workflow still runs the version lifecycle so that release-only metadata such as `versions.json` is recorded in the release commit.
|
||||
- Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`.
|
||||
- Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that the plug-in tag points to that commit, optionally creates the corresponding CLI tag, and explicitly dispatches the selected plug-in and CLI release workflows. The finalisation workflow can be retried when existing tags already point to the reviewed commit, but stops if a selected tag points elsewhere.
|
||||
- The plug-in publishing workflow is intentionally dispatch-only. Pushing a plug-in tag directly does not publish a GitHub Release; use `Finalise Release Tags`, or dispatch `Release Obsidian Plugin` explicitly for recovery or a pre-release. When CLI publication is selected, finalisation dispatches the CLI Docker workflow against the reviewed CLI tag instead of relying on a tag created by `GITHUB_TOKEN` to start another workflow.
|
||||
- For a hyphenated pre-release, run finalisation with `prerelease=true`; CLI publication remains optional. For a stable version awaiting BRAT validation, use `prerelease=true` and `publish_cli=false`.
|
||||
- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. When a hyphenated pre-release includes the CLI, confirm that it received only its immutable version and SHA-qualified image tags.
|
||||
- Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request.
|
||||
- Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that the plug-in tag points to that commit, optionally creates the corresponding CLI tag, and dispatches the plug-in release workflow. A CLI tag starts its own container workflow. The finalisation workflow can be retried when existing tags already point to the reviewed commit, but stops if a selected tag points elsewhere.
|
||||
- The plug-in publishing workflow is intentionally dispatch-only. Pushing a plug-in tag directly does not publish a GitHub Release; use `Finalise Release Tags`, or dispatch `Release Obsidian Plugin` explicitly for recovery or a pre-release. The CLI Docker workflow retains its documented branch, tag, and manual triggers.
|
||||
- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. For a selected CLI publication, confirm the image tags appropriate to a stable or pre-release version.
|
||||
- Publish a stable draft as the latest release, or publish a pre-release draft without replacing the latest stable release. In either case, keep the release PR in draft and leave its base branch unchanged until BRAT validation succeeds. Record that state in the PR.
|
||||
- Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release.
|
||||
- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action.
|
||||
- After a stable version passes, mark its release pull request ready and merge the exact release commit into the selected base branch with a merge commit. Integrate that exact commit through the reviewed branch chain into the repository's default branch. Only after the default branch contains the matching stable metadata, remove the GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
|
||||
- After BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit. This keeps the tagged release commit in that branch's history.
|
||||
- If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show <tag>:updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history.
|
||||
- Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action.
|
||||
- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`.
|
||||
- For a pre-release, set `prerelease=true` in `Finalise Release Tags`. A hyphenated version is rejected unless that input is enabled.
|
||||
|
||||
### Release Cheat Sheet
|
||||
|
||||
@@ -295,16 +290,15 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
|
||||
- `version`: the same target version.
|
||||
- `release_branch`: leave blank unless the release branch used a custom name.
|
||||
- `expected_head_sha`: the full head commit SHA reviewed in the release PR.
|
||||
- `prerelease`: enable for a version such as `1.0.0-rc.0`, and also when staging a stable version for BRAT.
|
||||
- `publish_cli`: optional for a hyphenated pre-release, but disable it when staging a stable version.
|
||||
- `prerelease`: enable for a version such as `1.0.0-rc.0`.
|
||||
- `publish_cli`: disable when the reviewed release is plug-in-only.
|
||||
5. Approve the `Release Obsidian Plugin` workflow for the `release` environment, then check the generated draft GitHub Release.
|
||||
6. If a hyphenated pre-release includes the CLI, confirm that the explicitly dispatched CLI workflow published only immutable version and SHA-qualified image tags.
|
||||
7. Publish the draft as a GitHub pre-release without replacing the latest stable release, but keep the release PR in draft and leave its base branch unchanged.
|
||||
8. Update the PR state message to describe the published pre-release and state that merging remains on hold.
|
||||
6. If CLI publication was selected, confirm that the CLI tag event published the expected image tags.
|
||||
7. Publish the draft as a stable release or pre-release as selected, but keep the release PR in draft and leave its base branch unchanged.
|
||||
8. Update the PR state message to describe the published release and state that merging remains on hold until BRAT validation is complete.
|
||||
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario.
|
||||
10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action.
|
||||
11. After a stable version passes, mark the release PR ready and merge the exact release commit into the selected base branch. Integrate that commit through the reviewed branch chain into the repository's default branch. Once the default branch contains the matching stable metadata, remove the pre-release designation, make the exact release the latest stable release, and publish stable CLI tags through a separate maintainer gate if selected.
|
||||
12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
|
||||
10. After BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit.
|
||||
11. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
|
||||
@@ -32,28 +32,6 @@ While suspended:
|
||||
|
||||
The flag deliberately enables file logging, which may affect performance. Remove it after the emergency has been understood.
|
||||
|
||||
## Recover a conflicted or mismatched file
|
||||
|
||||
Use this workflow when one file, or a small number of known files, has conflicts, missing chunks, or a difference between the current Vault file and the local LiveSync database. The inspection is device-local: it does not query a remote database or prove that another device has the same chunks.
|
||||
|
||||
The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other live version.
|
||||
|
||||
1. Stop editing the affected file, pause replication on the participating devices, and keep a separate copy of every readable version.
|
||||
2. If another device or backup has the intended content, preserve that copy before changing any revision.
|
||||
3. If the current Vault file is readable, select **Recreate current chunks**. This can restore only chunks derived from the current Vault contents; it cannot reconstruct unique bytes from an unavailable historical or conflict revision.
|
||||
4. Select **Inspect conflicts and file/database differences** → **Begin inspection**.
|
||||
5. Review the database winner, every conflict revision, and any unavailable shared ancestor separately. Revision identifiers, `Δsize`, `Δtime`, and chunk availability are diagnostic evidence; they do not decide which content is correct.
|
||||
6. Use the wrench menu on the exact revision:
|
||||
- **Compare with Vault** opens a read-only comparison for readable text.
|
||||
- **Apply this revision to Vault** replaces the Vault file with that readable database revision.
|
||||
- **Mark this revision as the Vault version** records an exact byte-for-byte match without creating a child revision.
|
||||
- **Store Vault file as a child of this revision** preserves the current Vault bytes on that selected branch.
|
||||
- **Retry reading revision** retries configured chunk retrieval without changing the revision tree.
|
||||
- **Apply logical deletion to Vault**, **Discard this branch**, and **Discard unreadable revision** are destructive decisions. Use them only after preserving every version which may still be needed.
|
||||
7. Synchronise the healthy source if chunks were restored, scan again, and confirm that the expected conflict or difference has disappeared before resuming ordinary editing.
|
||||
|
||||
An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another live branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files).
|
||||
|
||||
## Reset synchronisation on this device
|
||||
|
||||
Use this when the remote copy is trusted but this device's local LiveSync database is incomplete, corrupt, or no longer aligned with it.
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
# 1.0 preview release history
|
||||
|
||||
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](../../updates.md).
|
||||
|
||||
The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted.
|
||||
|
||||
## 1.0.0-rc.1
|
||||
|
||||
27th July, 2026
|
||||
|
||||
The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here).
|
||||
|
||||
### Important
|
||||
|
||||
- This candidate retains the plug-in behaviour prepared for rc.0. The version was advanced because release tags are immutable; rc.0 was stopped during CLI validation before a plug-in release was published.
|
||||
- This remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. The exact rc.1 plug-in and CLI artefacts will be validated separately after publication.
|
||||
|
||||
### CLI and release validation
|
||||
|
||||
- CLI release validation now generates Setup URIs through the supported ESM package interface, allowing the Docker test to reach the CLI container instead of stopping during test preparation.
|
||||
- The CLI Docker image now assigns its entrypoint permissions explicitly, so non-root execution does not depend on permissions inherited from the source checkout.
|
||||
- Release finalisation now explicitly dispatches the CLI container workflow when CLI publication is selected, rather than relying on a workflow-created tag to start another workflow.
|
||||
- Focused regression tests guard the ESM execution mode and deterministic container entrypoint permissions, while the existing release-workflow tests now require explicit CLI dispatch with non-dry-run, immutable-tag inputs.
|
||||
|
||||
### Testing
|
||||
|
||||
- The native CLI setup, put, cat, list, information, deletion, conflict-resolution, and revision-retrieval scenario completed with the packaged Commonlib dependency.
|
||||
- The same scenario completed through the rebuilt non-root Docker image.
|
||||
- The focused CLI and release-workflow unit tests passed after first demonstrating all three regressions against the unmodified implementation.
|
||||
|
||||
## 1.0.0-beta.5
|
||||
|
||||
26th July, 2026
|
||||
|
||||
### Improved
|
||||
|
||||
- **Inspect conflicts and file/database differences** now compares the current Vault file with the database winner and every live conflict revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain.
|
||||
- Each reported file and live revision now has a compact wrench menu. Its available actions can compare readable text, apply the selected revision to the Vault, record an exact byte match, store the Vault content as a child of the selected branch, retry retrieving missing chunks without changing the revision tree, or discard only the selected live branch after confirmation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent.
|
||||
|
||||
### Testing
|
||||
|
||||
- Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions.
|
||||
|
||||
## 1.0.0-beta.4
|
||||
|
||||
25th July, 2026
|
||||
|
||||
### Improved
|
||||
|
||||
- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation.
|
||||
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**.
|
||||
- Text in setup and review dialogues can now be selected for copying or translation.
|
||||
- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue.
|
||||
|
||||
### Fixed
|
||||
|
||||
- An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device.
|
||||
- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message.
|
||||
|
||||
### Testing
|
||||
|
||||
- Added regressions for revision repair, command availability, selectable dialogues, conflict-aware chunk reachability, device-progress safeguards, and compaction timeouts.
|
||||
- Added a real CouchDB integration test for logical chunk deletion, shared and conflict chunk retention, compaction completion, downstream replication, and content-addressed chunk recreation.
|
||||
- Added a real Obsidian encrypted reconnect scenario which replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation refreshes it without restoring the old value, and proves a bidirectional encrypted round-trip.
|
||||
|
||||
## 1.0.0-beta.3
|
||||
|
||||
24th July, 2026
|
||||
|
||||
### Improved
|
||||
|
||||
- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices.
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues.
|
||||
|
||||
## 1.0.0-beta.2
|
||||
|
||||
23rd July, 2026
|
||||
|
||||
### Improved
|
||||
|
||||
- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred.
|
||||
|
||||
### Testing
|
||||
|
||||
- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages.
|
||||
|
||||
## 1.0.0-beta.1
|
||||
|
||||
22nd July, 2026
|
||||
|
||||
### Important
|
||||
|
||||
- This corrected opt-in integration preview follows `1.0.0-beta.0` and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers.
|
||||
- Edits, deletions, and renames made while a file is conflicted now extend the exact revision displayed on that device. If LiveSync cannot prove the displayed branch, it preserves the affected branches for review instead of silently applying the operation to the database winner.
|
||||
|
||||
### Testing
|
||||
|
||||
- Added revision-tree regressions and focused real-Obsidian scenarios for propagated resolutions and file operations performed while a conflict remains active.
|
||||
|
||||
## 1.0.0-beta.0
|
||||
|
||||
22nd July, 2026
|
||||
|
||||
### Important
|
||||
|
||||
- This is an opt-in 1.0 integration preview for BRAT and testing with existing Vaults. It does not replace the latest stable release. Use it with a current backup, and update every participating device before resuming synchronisation.
|
||||
- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. The review preserves the existing automatic synchronisation choices and resumes them only after the decision has been saved successfully.
|
||||
|
||||
### Improved
|
||||
|
||||
- An unconfigured installation now waits for you to start setup. A long-lived Notice offers the setup action, and **Open onboarding wizard** remains available from the command palette instead of the dialogue opening automatically.
|
||||
- The setup wizard now creates named remote profiles for CouchDB, Object Storage, and P2P. Current Setup URIs preserve their profile names and selections, and the wizard reserves Rebuild or Fetch before the ordinary start-up scan begins.
|
||||
- Peer-to-Peer Synchronisation (P2P) and Hidden File Sync are supported opt-in features. JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews. Customisation Sync remains a supported advanced workflow.
|
||||
- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the Data Compression specification.
|
||||
- Compatibility review now runs before Config Doctor without overlapping it. Existing Vaults retain their automatic synchronisation choices and explicit file-name case setting. For installations created by earlier releases, LiveSync preserves whether setup had been completed and saves a missing legacy case setting as case-insensitive.
|
||||
- P2P connections now restart reliably after settings are reapplied or the local database is reset. Setup on an additional device asks you to select the source device once. Disconnecting leaves the LiveSync room and closes its signalling relay connections so that reconnecting can establish a new room.
|
||||
- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in issue #555.
|
||||
- Warnings about estimated remote storage size now appear as long-lived clickable Notices instead of timed dialogues. Initial uploads and Rebuild operations no longer prompt to send every chunk in advance; ordinary replication completes the transfer.
|
||||
- Removed the obsolete **Use the trash bin** control and the setting for fixed chunk revisions. Remote deletion still follows Obsidian's preference, and chunk revisions remain content-derived. The Change Log remains available but no longer opens automatically or tracks unread versions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The optional Custom HTTP Handler used by Object Storage now sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
|
||||
- When selectors, ignore files, size limits, modification-time limits, or file-name case settings are broadened, LiveSync now rechecks previously received files without requiring another remote update.
|
||||
- P2P setup on the first device no longer displays reset or upload steps for a central database, and Config Doctor now offers its chunk size recommendation for CouchDB only when a CouchDB remote profile is selected.
|
||||
|
||||
### Security
|
||||
|
||||
- Fly.io setup now generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates prevent excessive CPU use from specially crafted path patterns and `mailto:` links. The CLI rejects path traversal and symbolic-link components detected before Vault operations.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- Self-hosted LiveSync now owns its translation catalogue. Commonlib provides English messages to other applications, and translation contributions can be made directly to the Self-hosted LiveSync repository.
|
||||
|
||||
### Testing
|
||||
|
||||
- Expanded automated testing in Obsidian for upgrades, synchronisation between two devices, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, and clean-up after failures.
|
||||
+6
-6
@@ -731,16 +731,16 @@ Stop reflecting database changes to storage files.
|
||||
|
||||
Recreate chunks from files currently present in the Vault. This can repair missing chunks for those exact current contents after they have been confirmed as authoritative. It cannot reconstruct unavailable historical or conflict content.
|
||||
|
||||
#### Inspect conflicts and file/database differences
|
||||
|
||||
Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded.
|
||||
|
||||
Select **Begin inspection** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history.
|
||||
|
||||
#### Resolve All conflicted files by the newer one
|
||||
|
||||
After confirmation, resolve every conflict by modification time. This logically deletes every version except the newest one. It is a destructive policy choice and cannot recover content which is already unavailable.
|
||||
|
||||
#### Verify and repair all files
|
||||
|
||||
Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded.
|
||||
|
||||
`Retry reading revision` retries the configured chunk-retrieval path without changing the revision tree. `Discard unreadable revision` is offered only for an exact current live revision which remains unreadable; after confirmation, it creates a logical deletion for that revision. Prefer recovery from another replica or backup before discarding it.
|
||||
|
||||
#### Check and convert non-path-obfuscated files
|
||||
|
||||
### 4. Reset
|
||||
|
||||
@@ -50,24 +50,11 @@ The compatibility implementation currently selects the newer modification time f
|
||||
|
||||
A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing.
|
||||
|
||||
**Hatch** → **Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
|
||||
**Hatch** → **Verify and repair all files** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. It reports exact revision identifiers and local chunk availability separately:
|
||||
|
||||
Each reported live revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:
|
||||
|
||||
- **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files.
|
||||
- **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation.
|
||||
- **Mark this revision as the Vault version** is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection.
|
||||
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected live branch.
|
||||
- **Apply logical deletion to Vault** removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance.
|
||||
- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree.
|
||||
- **Discard this branch** is available for each exact live revision while at least one other live branch remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
|
||||
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only live leaf. It requires confirmation because no other database branch remains.
|
||||
|
||||
Every mutating action rechecks that the selected revision is still a current live leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
|
||||
|
||||
The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win.
|
||||
|
||||
A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
|
||||
- **Discard unreadable revision** is available only for a current winner or conflict revision which remains unreadable when the action is performed. It requires confirmation and creates a logical deletion for that exact revision.
|
||||
- A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
|
||||
|
||||
Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible.
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ Garbage Collection deliberately trades historical recoverability for storage. A
|
||||
|
||||
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
|
||||
|
||||
Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Inspect conflicts and file/database differences**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required.
|
||||
Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Verify and repair all files**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
|
||||
- Boot-up sequence (boot-sequence)
|
||||
- The initialisation process of the plug-in when Obsidian starts. It starts with the loading of the plug-in, setting up core services, loading saved settings, and opening the local database. Once the layout is ready, the plug-in checks for the presence of flag files, runs configuration diagnostics, connects to the remote database, and begins file watching. The sequence finishes once the plug-in is fully ready and operational.
|
||||
- Broken files (Size mismatch)
|
||||
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be inspected with `Inspect conflicts and file/database differences` on the Hatch pane, then handled one exact revision at a time.
|
||||
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be detected and resolved by running validation tools such as `Verify and repair all files` on the Hatch pane.
|
||||
- Chunk / Chunks
|
||||
- Divided units of data stored in the database or object storage to facilitate efficient synchronisation.
|
||||
- Compaction
|
||||
|
||||
@@ -57,12 +57,10 @@ If the log reports missing chunks or a size mismatch:
|
||||
2. restart Obsidian once to rule out an interrupted fetch;
|
||||
3. synchronise a device or restore a backup which still has the correct content;
|
||||
4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise;
|
||||
5. follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file); run `Inspect conflicts and file/database differences` from `Hatch`, then use each revision's wrench menu to review and act on that exact branch; and
|
||||
6. use `Discard this branch` only after confirming that the exact live branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole live leaf.
|
||||
5. run `Verify and repair all files` from `Hatch`; review the winner, every conflict revision, and any unavailable shared ancestor separately; and
|
||||
6. use `Discard unreadable revision` only after confirming that the exact revision is no longer recoverable or wanted.
|
||||
|
||||
The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other live branches still need a decision. Every mutating action rechecks that its selected revision is still live. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted.
|
||||
|
||||
`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact live revision while another live branch remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable live leaf. Neither action purges history or reconstructs missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
|
||||
`Retry reading revision` does not change the revision tree. `Discard unreadable revision` creates a logical deletion for one current winner or conflict revision after rechecking it. It does not purge history or reconstruct missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
|
||||
|
||||
`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision.
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.0-beta.4",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+17
-17
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.0-beta.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.0-beta.4",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -22,7 +22,7 @@
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0-rc.12",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -56,7 +56,7 @@
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.5",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
@@ -4764,9 +4764,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0.tgz",
|
||||
"integrity": "sha512-rdzEubzLStioanE67pps2XSGZ2UGyMIyeEoKsdPztQLLW8wM05PWApOPbJujIydHcDr2hFanbDFe89Lk7Mn4XQ==",
|
||||
"version": "0.1.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.12.tgz",
|
||||
"integrity": "sha512-pMiOL4x5pDKCYOwtH3nG+9Ra1sLjowItBUrYoHpvcrweV4NSN0OkAEMk1vxBQlKMmKtnrAdy0WuQvmsdLOWHqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -4839,9 +4839,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/obsidian-test-session": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.6.tgz",
|
||||
"integrity": "sha512-7xDTmTW3igBxwQGgbbpoRZU0JvOBpGgdRT4qF2WVrKz03OtKMJdkfJILY04/HZ+n4tvn9Ze8phJBr0dx3wewcQ==",
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.5.tgz",
|
||||
"integrity": "sha512-ZsI+Yx3z6IEFfh5Ey5mEUBNI0SUD6oDhP7D9LSZVEqPmdJz2JMiag7d8u/p1i6KpsoI2HbDvtw4RHpB+BQReAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5975,15 +5975,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -15913,7 +15913,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.0-cli",
|
||||
"version": "1.0.0-beta.4-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -15938,7 +15938,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.0-webapp",
|
||||
"version": "1.0.0-beta.4-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
},
|
||||
@@ -15950,7 +15950,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.0-webpeer",
|
||||
"version": "1.0.0-beta.4-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
},
|
||||
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.0-beta.4",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -12,7 +12,6 @@
|
||||
"buildDev": "node esbuild.config.mjs dev",
|
||||
"lint": "eslint --cache --cache-strategy content --concurrency off src",
|
||||
"lint:community": "eslint --config eslint.community.config.mjs --concurrency off src",
|
||||
"lint:community:tools": "eslint --config eslint.community.config.mjs --concurrency off --max-warnings 0 _tools",
|
||||
"svelte-check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
|
||||
"tsc-check": "tsc --noEmit",
|
||||
"tsc-check:apps": "tsc --noEmit -p src/apps/browser/tsconfig.json && tsc --noEmit -p src/apps/cli/tsconfig.json && tsc --noEmit -p src/apps/webapp/tsconfig.json && tsc --noEmit -p src/apps/webpeer/tsconfig.app.json && tsc --noEmit -p src/apps/webpeer/tsconfig.node.json",
|
||||
@@ -23,7 +22,7 @@
|
||||
"prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
|
||||
"precheck:compatibility": "npm run build",
|
||||
"check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15",
|
||||
"check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run lint:community:tools && npm run svelte-check && npm run check:compatibility",
|
||||
"check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run svelte-check && npm run check:compatibility",
|
||||
"i18n:bake": "npm run i18n:yaml2json && npm run i18n:bakejson && npm run i18n:format",
|
||||
"i18n:bakejson": "tsx _tools/bakei18n.ts",
|
||||
"i18n:format": "prettier --config .prettierrc.mjs --write --log-level error 'src/common/messagesJson/*.json' 'src/common/messages/*.ts'",
|
||||
@@ -117,7 +116,7 @@
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.5",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
@@ -166,7 +165,7 @@
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0-rc.12",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
|
||||
@@ -101,9 +101,9 @@ COPY --from=runtime-deps /deps/node_modules ./node_modules
|
||||
# Copy the built CLI bundle from builder stage
|
||||
COPY --from=builder /build/src/apps/cli/dist ./dist
|
||||
|
||||
# Install the entrypoint wrapper with a deterministic mode, regardless of
|
||||
# source checkout permissions.
|
||||
COPY --chmod=755 src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli
|
||||
# Install entrypoint wrapper
|
||||
COPY src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli
|
||||
RUN chmod +x /usr/local/bin/livesync-cli
|
||||
|
||||
# Mount your vault / local database directory here
|
||||
VOLUME ["/data"]
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dockerfile = readFileSync(new URL("./Dockerfile", import.meta.url), "utf8");
|
||||
|
||||
describe("CLI Docker image", () => {
|
||||
it("sets a deterministic readable and executable entrypoint mode", () => {
|
||||
expect(dockerfile).toContain("COPY --chmod=755 src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli");
|
||||
expect(dockerfile).not.toContain("RUN chmod +x /usr/local/bin/livesync-cli");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.0-cli",
|
||||
"version": "1.0.0-beta.4-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const setupPutCatHelper = readFileSync(new URL("./test/test-setup-put-cat-linux.sh", import.meta.url), "utf8");
|
||||
|
||||
describe("CLI setup URI E2E helper", () => {
|
||||
it("evaluates Commonlib package imports as ESM", () => {
|
||||
expect(setupPutCatHelper).toContain("node --input-type=module -e");
|
||||
expect(setupPutCatHelper).not.toContain("npx tsx -e");
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ cli_test_init_settings_file "$SETTINGS_FILE"
|
||||
|
||||
echo "[INFO] creating setup URI from settings"
|
||||
SETUP_URI="$(
|
||||
SETTINGS_FILE="$SETTINGS_FILE" SETUP_PASSPHRASE="$SETUP_PASSPHRASE" node --input-type=module -e '
|
||||
SETTINGS_FILE="$SETTINGS_FILE" SETUP_PASSPHRASE="$SETUP_PASSPHRASE" npx tsx -e '
|
||||
import { fs } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
(async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.0-webapp",
|
||||
"version": "1.0.0-beta.4-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.0-webpeer",
|
||||
"version": "1.0.0-beta.4-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -74,51 +74,21 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Database information for ${FILE}": "Database information for ${FILE}",
|
||||
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.":
|
||||
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.",
|
||||
"📁 Vault: ${SIZE} B · ${TIME}": "📁 Vault: ${SIZE} B · ${TIME}",
|
||||
"📁 Vault: missing": "📁 Vault: missing",
|
||||
"🗄️ Local DB: missing": "🗄️ Local DB: missing",
|
||||
"Vault and database revision": "Vault and database revision",
|
||||
"Vault file": "Vault file",
|
||||
"Database revision": "Database revision",
|
||||
"Vault file is newer": "Vault file is newer",
|
||||
"Database revision is newer": "Database revision is newer",
|
||||
"Within the two-second comparison window": "Within the two-second comparison window",
|
||||
"Timestamp comparison unavailable": "Timestamp comparison unavailable",
|
||||
"Vault file: modified ${TIME}, size ${SIZE}": "Vault file: modified ${TIME}, size ${SIZE}",
|
||||
"Vault file: missing": "Vault file: missing",
|
||||
"Local database document: missing": "Local database document: missing",
|
||||
"${ROLE}: ${REVISION}": "${ROLE}: ${REVISION}",
|
||||
"Winner revision": "Winner revision",
|
||||
"Conflict revision": "Conflict revision",
|
||||
"Unknown revision": "Unknown revision",
|
||||
"🗑️ Logical deletion": "🗑️ Logical deletion",
|
||||
"Logical deletion": "Logical deletion",
|
||||
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}":
|
||||
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}",
|
||||
"🧩 Missing chunks: ${COUNT}": "🧩 Missing chunks: ${COUNT}",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B":
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable":
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable",
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B":
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})":
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
|
||||
"✅ Matches Vault": "✅ Matches Vault",
|
||||
"⚠️ Differs from Vault": "⚠️ Differs from Vault",
|
||||
"✅ Vault matches winner": "✅ Vault matches winner",
|
||||
"⚠️ Conflicts: ${COUNT}": "⚠️ Conflicts: ${COUNT}",
|
||||
"Compare with Vault": "Compare with Vault",
|
||||
"Apply this revision to Vault": "Apply this revision to Vault",
|
||||
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.":
|
||||
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.",
|
||||
"Apply database revision to Vault": "Apply database revision to Vault",
|
||||
"Mark this revision as the Vault version": "Mark this revision as the Vault version",
|
||||
"Store Vault file as a child of this revision": "Store Vault file as a child of this revision",
|
||||
"Apply logical deletion to Vault": "Apply logical deletion to Vault",
|
||||
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.":
|
||||
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.",
|
||||
"Unreadable on this device; ${COUNT} referenced chunks are missing or deleted":
|
||||
"Unreadable on this device; ${COUNT} referenced chunks are missing or deleted",
|
||||
"Matches the current Vault file": "Matches the current Vault file",
|
||||
"Differs from the current Vault file": "Differs from the current Vault file",
|
||||
"Retry reading revision": "Retry reading revision",
|
||||
"Discard this branch": "Discard this branch",
|
||||
"Discard branch": "Discard branch",
|
||||
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.":
|
||||
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.",
|
||||
"Discard unreadable revision": "Discard unreadable revision",
|
||||
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.":
|
||||
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.",
|
||||
@@ -127,11 +97,9 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.",
|
||||
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.":
|
||||
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.",
|
||||
"More actions for revision ${REVISION}": "More actions for revision ${REVISION}",
|
||||
"More actions for ${FILE}": "More actions for ${FILE}",
|
||||
"Show revision history": "Show revision history",
|
||||
"Store Vault file as a new local database document":
|
||||
"Store Vault file as a new local database document",
|
||||
"Use Vault file in local database": "Use Vault file in local database",
|
||||
"Restore database winner to Vault": "Restore database winner to Vault",
|
||||
"Copy database information": "Copy database information",
|
||||
"Recreate chunks for current Vault files": "Recreate chunks for current Vault files",
|
||||
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.":
|
||||
@@ -140,11 +108,10 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.":
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
|
||||
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
|
||||
"Inspect conflicts and file/database differences":
|
||||
"Inspect conflicts and file/database differences",
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
|
||||
"Begin inspection": "Begin inspection",
|
||||
"Verify and repair all files": "Verify and repair all files",
|
||||
"Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.":
|
||||
"Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.",
|
||||
"Verify all": "Verify all",
|
||||
"Connection settings": "Connection settings",
|
||||
"Saved connections": "Saved connections",
|
||||
} as const;
|
||||
|
||||
@@ -26,7 +26,6 @@ export {
|
||||
WorkspaceLeaf,
|
||||
Menu,
|
||||
request,
|
||||
setIcon,
|
||||
getLanguage,
|
||||
requireApiVersion,
|
||||
ButtonComponent,
|
||||
|
||||
@@ -1530,29 +1530,6 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async getLiveInternalRevision(
|
||||
prefixedFileName: FilePathWithPrefix,
|
||||
revision: string
|
||||
): Promise<MetaEntry | false> {
|
||||
const [selected, current, conflicts] = await Promise.all([
|
||||
this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, revision, true),
|
||||
this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, undefined, true),
|
||||
this.core.databaseFileAccess.getConflictedRevs(prefixedFileName),
|
||||
]);
|
||||
const liveRevisions = new Set([
|
||||
...(current && current._rev ? [current._rev] : []),
|
||||
...conflicts,
|
||||
]);
|
||||
if (!selected || selected._rev !== revision || !liveRevisions.has(revision)) {
|
||||
this._log(
|
||||
`Could not use hidden-file revision ${revision} of ${stripAllPrefixes(prefixedFileName)}; the selected revision is no longer live`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
const storeFilePath = stripAllPrefixes(file.path);
|
||||
const storageFilePath = file.path;
|
||||
@@ -1604,79 +1581,6 @@ Offline Changed files: ${files.length}`;
|
||||
});
|
||||
}
|
||||
|
||||
async storeInternalFileToDatabaseWithBaseRevision(
|
||||
file: InternalFileInfo | UXFileInfo,
|
||||
baseRevision: string,
|
||||
createIfDifferent = true
|
||||
): Promise<boolean> {
|
||||
const storeFilePath = stripAllPrefixes(file.path);
|
||||
const storageFilePath = file.path;
|
||||
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
|
||||
return false;
|
||||
}
|
||||
const prefixedFileName = addPrefix(storeFilePath, ICHeader);
|
||||
|
||||
return await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
const baseData = await this.getLiveInternalRevision(prefixedFileName, baseRevision);
|
||||
if (baseData === false) {
|
||||
return false;
|
||||
}
|
||||
const fileInfo = "stat" in file && "body" in file ? file : await this.loadFileWithInfo(storeFilePath);
|
||||
if (fileInfo.deleted) {
|
||||
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
|
||||
}
|
||||
if (!baseData.deleted && !baseData._deleted) {
|
||||
const loadedBase = await this.core.databaseFileAccess.fetchEntryFromMeta(baseData, true, true);
|
||||
if (loadedBase && (await isDocContentSame(readAsBlob(loadedBase), fileInfo.body))) {
|
||||
this.updateLastProcessed(storeFilePath, baseData, fileInfo.stat);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!createIfDifferent) {
|
||||
this._log(
|
||||
`Could not mark hidden file ${storeFilePath} as revision ${baseRevision}; the storage content differs`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const storedRevision = await this.core.databaseFileAccess.storeWithBaseRevision(
|
||||
{
|
||||
...fileInfo,
|
||||
path: storeFilePath,
|
||||
name: fileInfo.name || storeFilePath.split("/").pop() || "",
|
||||
isInternal: true,
|
||||
},
|
||||
baseRevision,
|
||||
true
|
||||
);
|
||||
if (storedRevision === false) {
|
||||
return false;
|
||||
}
|
||||
this.updateLastProcessed(
|
||||
storeFilePath,
|
||||
{
|
||||
...baseData,
|
||||
_rev: storedRevision,
|
||||
path: prefixedFileName,
|
||||
ctime: fileInfo.stat.ctime,
|
||||
mtime: fileInfo.stat.mtime,
|
||||
size: fileInfo.stat.size,
|
||||
deleted: false,
|
||||
},
|
||||
fileInfo.stat
|
||||
);
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Done`);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
|
||||
const storeFilePath = filenameSrc;
|
||||
const storageFilePath = filenameSrc;
|
||||
@@ -1740,8 +1644,7 @@ Offline Changed files: ${files.length}`;
|
||||
metaEntry?: MetaEntry | LoadedEntry,
|
||||
preventDoubleProcess = true,
|
||||
onlyNew = false,
|
||||
includeDeletion = true,
|
||||
requiredLiveRevision?: string
|
||||
includeDeletion = true
|
||||
) {
|
||||
const prefixedFileName = addPrefix(storageFilePath, ICHeader);
|
||||
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
|
||||
@@ -1750,11 +1653,9 @@ Offline Changed files: ${files.length}`;
|
||||
return await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
// Check conflicted status
|
||||
const metaOnDB = requiredLiveRevision
|
||||
? await this.getLiveInternalRevision(prefixedFileName, requiredLiveRevision)
|
||||
: metaEntry
|
||||
? metaEntry
|
||||
: await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true);
|
||||
const metaOnDB = metaEntry
|
||||
? metaEntry
|
||||
: await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true);
|
||||
if (metaOnDB === false) throw new Error(`File not found on database.:${storageFilePath}`);
|
||||
// Prevent overwrite for Prevent overwriting while some conflicted revision exists.
|
||||
if (metaOnDB?._conflicts?.length) {
|
||||
@@ -1828,24 +1729,6 @@ Offline Changed files: ${files.length}`;
|
||||
});
|
||||
}
|
||||
|
||||
async extractInternalFileRevisionFromDatabase(
|
||||
storageFilePath: FilePath,
|
||||
revision: string,
|
||||
force = false
|
||||
): Promise<boolean> {
|
||||
return Boolean(
|
||||
await this.extractInternalFileFromDatabase(
|
||||
storageFilePath,
|
||||
force,
|
||||
undefined,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
revision
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async __checkIsNeedToWriteFile(storageFilePath: FilePath, content: string | ArrayBuffer): Promise<boolean> {
|
||||
try {
|
||||
const storageContent = await this.core.storageAccess.readHiddenFileAuto(storageFilePath);
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type DocumentID,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
type UXFileInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
@@ -34,70 +27,6 @@ vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
|
||||
|
||||
function createHiddenRevisionOperation() {
|
||||
const path = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
const file = {
|
||||
path,
|
||||
name: "data.json",
|
||||
isInternal: true,
|
||||
body: new Blob(["{\"value\":\"vault\"}"]),
|
||||
stat: {
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 17,
|
||||
type: "file",
|
||||
},
|
||||
} as UXFileInfo;
|
||||
const selected = {
|
||||
_id: "i:example" as DocumentID,
|
||||
_rev: "2-selected",
|
||||
path: `i:${path}` as FilePathWithPrefix,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 17,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: [],
|
||||
eden: {},
|
||||
deleted: false,
|
||||
} as MetaEntry;
|
||||
const winner = {
|
||||
...selected,
|
||||
_rev: "3-winner",
|
||||
} as MetaEntry;
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(
|
||||
async (_path: unknown, revision?: string) =>
|
||||
revision === selected._rev ? selected : winner
|
||||
),
|
||||
getConflictedRevs: vi.fn(async () => [selected._rev]),
|
||||
fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: "{\"value\":\"database\"}" })),
|
||||
storeWithBaseRevision: vi.fn(async () => "3-vault-child"),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
services: {
|
||||
vault: {
|
||||
isIgnoredByIgnoreFile: vi.fn(async () => false),
|
||||
},
|
||||
},
|
||||
databaseFileAccess,
|
||||
},
|
||||
loadFileWithInfo: vi.fn(async () => file),
|
||||
updateLastProcessed: vi.fn(),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
file,
|
||||
selected,
|
||||
winner,
|
||||
databaseFileAccess,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HiddenFileSync configuration-change notices", () => {
|
||||
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
|
||||
const commands: Array<{
|
||||
@@ -341,130 +270,3 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
expect(progress.done).toHaveBeenCalledWith("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiddenFileSync exact revision repair operations", () => {
|
||||
it("stores the current hidden Vault file as a child of the selected live revision", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: file.path,
|
||||
body: file.body,
|
||||
isInternal: true,
|
||||
}),
|
||||
selected._rev,
|
||||
true
|
||||
);
|
||||
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
|
||||
file.path,
|
||||
expect.objectContaining({ _rev: "3-vault-child" }),
|
||||
file.stat
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to extend a hidden-file revision which is no longer live", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
|
||||
file,
|
||||
selected._rev!,
|
||||
false
|
||||
)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks a matching hidden-file revision without creating a child", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({
|
||||
...selected,
|
||||
data: "{\"value\":\"vault\"}",
|
||||
});
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
|
||||
file,
|
||||
selected._rev!,
|
||||
false
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
|
||||
file.path,
|
||||
selected,
|
||||
file.stat
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the selected live hidden-file revision through the existing extraction path", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
selected,
|
||||
} = createHiddenRevisionOperation();
|
||||
const extract = vi.fn(async () => true);
|
||||
hiddenFileSync.extractInternalFileFromDatabase = extract;
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev);
|
||||
});
|
||||
|
||||
it("does not apply a hidden-file revision which ceased to be live", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,6 @@ export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
export type ConflictResolveModalOptions = {
|
||||
readOnly?: boolean;
|
||||
title?: string;
|
||||
localName?: string;
|
||||
remoteName?: string;
|
||||
};
|
||||
|
||||
export class ConflictResolveModal extends Modal {
|
||||
result: diff_result;
|
||||
filename: FilePathWithPrefix;
|
||||
@@ -32,7 +25,6 @@ export class ConflictResolveModal extends Modal {
|
||||
title: string = "Conflicting changes";
|
||||
|
||||
pluginPickMode: boolean = false;
|
||||
readOnly: boolean = false;
|
||||
localName: string = "Base";
|
||||
remoteName: string = "Conflicted";
|
||||
offEvent?: ReturnType<typeof eventHub.onEvent>;
|
||||
@@ -45,22 +37,16 @@ export class ConflictResolveModal extends Modal {
|
||||
filename: FilePathWithPrefix,
|
||||
diff: diff_result,
|
||||
pluginPickMode?: boolean,
|
||||
remoteName?: string,
|
||||
options?: ConflictResolveModalOptions
|
||||
remoteName?: string
|
||||
) {
|
||||
super(app);
|
||||
this.result = diff;
|
||||
this.filename = filename;
|
||||
this.pluginPickMode = pluginPickMode || false;
|
||||
this.readOnly = options?.readOnly ?? false;
|
||||
if (this.pluginPickMode) {
|
||||
this.title = "Pick a version";
|
||||
this.remoteName = `${remoteName || "Remote"}`;
|
||||
this.localName = "Local";
|
||||
} else if (this.readOnly) {
|
||||
this.title = options?.title ?? "Vault and database revision";
|
||||
this.localName = options?.localName ?? "Vault file";
|
||||
this.remoteName = options?.remoteName ?? "Database revision";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,18 +101,16 @@ export class ConflictResolveModal extends Modal {
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
if (!this.readOnly) {
|
||||
// Cancel an older dialogue for this path before subscribing this
|
||||
// instance. Emitting after subscription would close the replacement
|
||||
// itself; the instance-owned result promise then completes the older
|
||||
// caller even when it only begins waiting after this event.
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
|
||||
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Cancel an older dialogue for this path before subscribing this
|
||||
// instance. Emitting after subscription would close the replacement
|
||||
// itself; the instance-owned result promise then completes the older
|
||||
// caller even when it only begins waiting after this event.
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
|
||||
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
@@ -175,31 +159,24 @@ export class ConflictResolveModal extends Modal {
|
||||
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||
const actionContainer = contentEl.createDiv("conflict-action-container");
|
||||
if (this.readOnly) {
|
||||
actionContainer.createEl("button", { text: "Close" }, (e) => {
|
||||
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
|
||||
});
|
||||
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
|
||||
});
|
||||
if (!this.pluginPickMode) {
|
||||
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED));
|
||||
});
|
||||
} else {
|
||||
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
|
||||
});
|
||||
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
|
||||
});
|
||||
if (!this.pluginPickMode) {
|
||||
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
});
|
||||
}
|
||||
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
});
|
||||
}
|
||||
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
|
||||
});
|
||||
if (diffLength > 100 * 1024) {
|
||||
this.diffView.empty();
|
||||
this.diffView.setText("(Too large diff to display)");
|
||||
|
||||
@@ -5,8 +5,6 @@ import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/li
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class App {},
|
||||
Modal: class Modal {
|
||||
createdButtons: string[] = [];
|
||||
|
||||
private createElement(): Record<string, unknown> {
|
||||
const element: Record<string, unknown> = {
|
||||
addClass: vi.fn(),
|
||||
@@ -24,14 +22,6 @@ vi.mock("@/deps.ts", () => ({
|
||||
};
|
||||
element.createDiv = vi.fn(() => this.createElement());
|
||||
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
|
||||
if (
|
||||
_tag === "button" &&
|
||||
typeof _options === "object" &&
|
||||
_options !== null &&
|
||||
"text" in _options
|
||||
) {
|
||||
this.createdButtons.push(String((_options as { text: unknown }).text));
|
||||
}
|
||||
const child = this.createElement();
|
||||
callback?.(child);
|
||||
return child;
|
||||
@@ -92,55 +82,4 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
expect(previousResult).toBe(CANCELLED);
|
||||
expect(replacementState).toBe("still-open");
|
||||
});
|
||||
|
||||
it("renders a read-only comparison with no resolution actions", () => {
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal & { createdButtons: string[] };
|
||||
const modal = new ReadOnlyModal(
|
||||
{},
|
||||
"repair-preview.md",
|
||||
conflict,
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
readOnly: true,
|
||||
title: "Vault and database revision",
|
||||
localName: "Vault file",
|
||||
remoteName: "Database revision",
|
||||
}
|
||||
);
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
expect(modal.createdButtons).toContain("Close");
|
||||
expect(modal.createdButtons).not.toContain("Use Vault file");
|
||||
expect(modal.createdButtons).not.toContain("Use Database revision");
|
||||
expect(modal.createdButtons).not.toContain("Concat both");
|
||||
expect(modal.createdButtons).not.toContain("Not now");
|
||||
modal.close();
|
||||
});
|
||||
|
||||
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
|
||||
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
|
||||
const previous = new ConflictResolveModal({} as never, filename, conflict);
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal;
|
||||
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
});
|
||||
previous.onOpen();
|
||||
|
||||
comparison.onOpen();
|
||||
const previousState = await Promise.race([
|
||||
previous.waitForResult(),
|
||||
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
|
||||
]);
|
||||
|
||||
previous.sendResponse(CANCELLED);
|
||||
comparison.close();
|
||||
|
||||
expect(previousState).toBe("still-open");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,14 +3,13 @@ import {
|
||||
type DocumentID,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type MetaEntry,
|
||||
type FilePath,
|
||||
type EntryDoc,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createBlob, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { Menu, diff_match_patch, setIcon } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
|
||||
eventHub,
|
||||
} from "@/common/events.ts";
|
||||
import { ICHeader } from "@/common/types.ts";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
@@ -33,17 +33,11 @@ import {
|
||||
retryReadFileDatabaseRevision,
|
||||
} from "@/serviceFeatures/fileDatabaseInfo.ts";
|
||||
import {
|
||||
discardLiveBranch,
|
||||
discardUnreadableLiveRevision,
|
||||
inspectFileRepair,
|
||||
type FileRepairInspection,
|
||||
type FileRepairRevision,
|
||||
} from "@/serviceFeatures/fileRepair.ts";
|
||||
import {
|
||||
getFileRepairRevisionActions,
|
||||
getFileRepairRevisionComparison,
|
||||
} from "@/serviceFeatures/fileRepairPresentation.ts";
|
||||
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
|
||||
// hatchWarn.addClass("op-warn-info");
|
||||
@@ -129,196 +123,57 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
|
||||
void addPanel(paneEl, "Recovery and Repair").then((paneEl) => {
|
||||
const resultArea = paneEl.createDiv({ text: "", cls: "sls-repair-results" });
|
||||
type RepairMenuAction = {
|
||||
title: string;
|
||||
run: () => Promise<void> | void;
|
||||
warning?: boolean;
|
||||
};
|
||||
const addActionMenu = (
|
||||
const addActionButton = (
|
||||
parent: HTMLElement,
|
||||
label: string,
|
||||
actions: RepairMenuAction[]
|
||||
text: string,
|
||||
action: (button: HTMLButtonElement) => Promise<void> | void,
|
||||
warning = false
|
||||
) => {
|
||||
if (actions.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.createEl(parent, "button", { cls: "sls-repair-action-menu" }, (button) => {
|
||||
setIcon(button, "wrench");
|
||||
button.setAttr("aria-label", label);
|
||||
button.setAttr("title", label);
|
||||
button.onClickEvent(() => {
|
||||
const menu = new Menu();
|
||||
for (const action of actions) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(action.title);
|
||||
if (action.warning) {
|
||||
item.setWarning(true);
|
||||
}
|
||||
item.onClick(() => {
|
||||
button.disabled = true;
|
||||
void Promise.resolve()
|
||||
.then(() => action.run())
|
||||
.catch((error) => {
|
||||
Logger(error, LOG_LEVEL_VERBOSE);
|
||||
Logger(
|
||||
`Repair action '${action.title}' failed`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (button.isConnected) {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
this.createEl(parent, "button", { text }, (button) => {
|
||||
if (warning) {
|
||||
button.addClass("mod-warning");
|
||||
}
|
||||
button.onClickEvent(async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
await action(button);
|
||||
} finally {
|
||||
if (button.isConnected) {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
const rect = button.getBoundingClientRect();
|
||||
menu.showAtPosition({ x: rect.left, y: rect.bottom });
|
||||
});
|
||||
});
|
||||
};
|
||||
const findHiddenFile = async (path: string) => {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
if (!addOn) {
|
||||
return false;
|
||||
}
|
||||
const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path);
|
||||
if (!file) {
|
||||
Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return { addOn, file };
|
||||
};
|
||||
const storeStorageInDatabase = async (path: string): Promise<boolean> => {
|
||||
if (path.startsWith(".")) {
|
||||
const hidden = await findHiddenFile(path);
|
||||
return hidden
|
||||
? Boolean(await hidden.addOn.storeInternalFileToDatabase(hidden.file, true))
|
||||
: false;
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
if (!addOn) {
|
||||
return false;
|
||||
}
|
||||
const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path);
|
||||
if (!file) {
|
||||
Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return Boolean(await addOn.storeInternalFileToDatabase(file, true));
|
||||
}
|
||||
return Boolean(await this.core.fileHandler.storeFileToDB(path as FilePath, true));
|
||||
};
|
||||
const storeStorageOnRevision = async (
|
||||
const applyWinnerToStorage = async (
|
||||
path: string,
|
||||
revision: string,
|
||||
createIfDifferent = true
|
||||
revision: FileRepairRevision
|
||||
): Promise<boolean> => {
|
||||
if (path.startsWith(".")) {
|
||||
const hidden = await findHiddenFile(path);
|
||||
return hidden
|
||||
? Boolean(
|
||||
await hidden.addOn.storeInternalFileToDatabaseWithBaseRevision(
|
||||
hidden.file,
|
||||
revision,
|
||||
createIfDifferent
|
||||
)
|
||||
)
|
||||
: false;
|
||||
}
|
||||
return Boolean(
|
||||
await this.core.fileHandler.storeFileToDBWithBaseRevision(
|
||||
path as FilePath,
|
||||
revision,
|
||||
createIfDifferent
|
||||
)
|
||||
);
|
||||
};
|
||||
const applyRevisionToStorage = async (
|
||||
path: string,
|
||||
revision: string,
|
||||
force: boolean
|
||||
): Promise<boolean> => {
|
||||
if (path.startsWith(".")) {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
return addOn
|
||||
? Boolean(
|
||||
await addOn.extractInternalFileRevisionFromDatabase(
|
||||
path as FilePath,
|
||||
revision,
|
||||
force
|
||||
)
|
||||
)
|
||||
: false;
|
||||
}
|
||||
return Boolean(
|
||||
await this.core.fileHandler.dbToStorageWithSpecificRev(
|
||||
path as FilePath,
|
||||
revision,
|
||||
force
|
||||
)
|
||||
);
|
||||
};
|
||||
const openRevisionComparison = async (
|
||||
path: string,
|
||||
selectedRevision: string
|
||||
): Promise<boolean> => {
|
||||
const latest = await inspectFileRepair(this.core, path);
|
||||
const revision = latest.revisions.find(
|
||||
({ metadata }) => metadata.revision === selectedRevision
|
||||
);
|
||||
if (
|
||||
!latest.information.storage.exists ||
|
||||
!revision ||
|
||||
revision.loadedEntry === false
|
||||
) {
|
||||
Logger(
|
||||
`Could not compare ${path} revision ${selectedRevision}; the Vault file or selected live revision is no longer readable`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
if (revision.loadedEntry === false) {
|
||||
return false;
|
||||
}
|
||||
const vaultText = await createBlob(
|
||||
await this.core.storageAccess.readHiddenFileBinary(path)
|
||||
).text();
|
||||
const databaseText = await readAsBlob(revision.loadedEntry).text();
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(vaultText, databaseText);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
const result: diff_result = {
|
||||
left: {
|
||||
rev: "vault",
|
||||
data: vaultText,
|
||||
ctime: latest.information.storage.ctime ?? 0,
|
||||
mtime: latest.information.storage.mtime ?? 0,
|
||||
},
|
||||
right: {
|
||||
rev: selectedRevision,
|
||||
data: databaseText,
|
||||
ctime: revision.metadata.ctime,
|
||||
mtime: revision.metadata.mtime,
|
||||
},
|
||||
diff,
|
||||
};
|
||||
new ConflictResolveModal(
|
||||
this.app,
|
||||
path as FilePathWithPrefix,
|
||||
result,
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
readOnly: true,
|
||||
title: $msg("Vault and database revision"),
|
||||
localName: $msg("Vault file"),
|
||||
remoteName: $msg("Database revision"),
|
||||
}
|
||||
).open();
|
||||
return true;
|
||||
};
|
||||
const formatSigned = (value: number) => `${value >= 0 ? "+" : ""}${value}`;
|
||||
const timestampRelationLabel = (
|
||||
relation: ReturnType<typeof getFileRepairRevisionComparison>["timestampRelation"]
|
||||
) => {
|
||||
switch (relation) {
|
||||
case "vault-newer":
|
||||
return $msg("Vault file is newer");
|
||||
case "database-newer":
|
||||
return $msg("Database revision is newer");
|
||||
case "same-window":
|
||||
return $msg("Within the two-second comparison window");
|
||||
default:
|
||||
return $msg("Timestamp comparison unavailable");
|
||||
if (revision.loadedEntry.path.startsWith(ICHeader)) {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
return addOn
|
||||
? Boolean(await addOn.extractInternalFileFromDatabase(path as FilePath, true))
|
||||
: false;
|
||||
}
|
||||
return Boolean(await this.core.fileHandler.dbToStorage(revision.loadedEntry as MetaEntry, null, true));
|
||||
};
|
||||
const addRepairResult = (inspection: FileRepairInspection) => {
|
||||
const { information, revisions } = inspection;
|
||||
@@ -333,128 +188,40 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
Logger(`Verification no longer reports a problem for ${path}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
};
|
||||
const runMutation = async (
|
||||
description: string,
|
||||
mutation: () => Promise<boolean>
|
||||
) => {
|
||||
try {
|
||||
const succeeded = await mutation();
|
||||
if (!succeeded) {
|
||||
Logger(`${description} failed: ${path}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
} finally {
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
const discardLiveBranchAction = (revision: string): RepairMenuAction => ({
|
||||
title: $msg("Discard this branch"),
|
||||
warning: true,
|
||||
run: async () => {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.",
|
||||
{
|
||||
REVISION: revision,
|
||||
FILE: path,
|
||||
}
|
||||
),
|
||||
{
|
||||
title: $msg("Discard branch"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const result = await discardLiveBranch(this.core, path, revision);
|
||||
Logger(
|
||||
`Discard database branch ${revision} of ${path}: ${result}`,
|
||||
result === "discarded" ? LOG_LEVEL_NOTICE : LOG_LEVEL_VERBOSE
|
||||
);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const fileHeader = this.createEl(card, "div", { cls: "sls-repair-header" });
|
||||
this.createEl(fileHeader, "h6", { text: path });
|
||||
const fileMenuHost = this.createEl(fileHeader, "div");
|
||||
this.createEl(card, "h6", { text: path });
|
||||
if (information.storage.exists) {
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("📁 Vault: ${SIZE} B · ${TIME}", {
|
||||
text: $msg("Vault file: modified ${TIME}, size ${SIZE}", {
|
||||
TIME: new Date(information.storage.mtime ?? 0).toLocaleString(),
|
||||
SIZE: `${information.storage.size ?? 0}`,
|
||||
}),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
} else {
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("📁 Vault: missing"),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
this.createEl(card, "div", { text: $msg("Vault file: missing") });
|
||||
}
|
||||
if (!information.database.exists) {
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("🗄️ Local DB: missing"),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
}
|
||||
if (information.database.conflictCount > 0) {
|
||||
const winner = revisions.find(({ role }) => role === "winner");
|
||||
const vaultMatchesWinner =
|
||||
winner !== undefined &&
|
||||
(winner.metadata.deleted
|
||||
? !information.storage.exists
|
||||
: information.storage.exists &&
|
||||
winner.contentMatchesStorage === true);
|
||||
const status = this.createEl(card, "div", { cls: "sls-repair-status" });
|
||||
if (vaultMatchesWinner) {
|
||||
this.createEl(status, "span", {
|
||||
text: $msg("✅ Vault matches winner"),
|
||||
cls: "sls-repair-status-ok",
|
||||
});
|
||||
}
|
||||
this.createEl(status, "span", {
|
||||
text: $msg("⚠️ Conflicts: ${COUNT}", {
|
||||
COUNT: `${information.database.conflictCount}`,
|
||||
}),
|
||||
cls: "sls-repair-status-warning",
|
||||
});
|
||||
this.createEl(card, "div", { text: $msg("Local database document: missing") });
|
||||
}
|
||||
|
||||
const addRevision = (revision: FileRepairRevision) => {
|
||||
const { metadata } = revision;
|
||||
const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" });
|
||||
const revisionHeader = this.createEl(revisionEl, "div", {
|
||||
cls: "sls-repair-header",
|
||||
});
|
||||
this.createEl(revisionHeader, "div", {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("${ROLE}: ${REVISION}", {
|
||||
ROLE: revision.role === "winner" ? $msg("Winner revision") : $msg("Conflict revision"),
|
||||
REVISION: metadata.revision ?? $msg("Unknown revision"),
|
||||
}),
|
||||
cls: "sls-repair-revision-title",
|
||||
});
|
||||
const revisionMenuHost = this.createEl(revisionHeader, "div");
|
||||
const comparison = getFileRepairRevisionComparison(inspection, revision);
|
||||
if (metadata.deleted) {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("🗑️ Logical deletion"),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
this.createEl(revisionEl, "div", { text: $msg("Logical deletion") });
|
||||
} else if (revision.contentReadable) {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg(
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
|
||||
{
|
||||
RECORDED: `${comparison.recordedSize}`,
|
||||
DECODED: `${comparison.decodedSize ?? 0}`,
|
||||
DIFFERENCE: formatSigned(
|
||||
comparison.recordedToDecodedSizeDifference ?? 0
|
||||
),
|
||||
}
|
||||
),
|
||||
cls: "sls-repair-metric",
|
||||
text: $msg("Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}", {
|
||||
RECORDED: `${metadata.recordedSize}`,
|
||||
ACTUAL: `${revision.loadedEntry === false ? 0 : readAsBlob(revision.loadedEntry).size}`,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const missing = metadata.chunks.filter(
|
||||
@@ -462,16 +229,10 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
!embedded && localDatabaseState !== "available"
|
||||
);
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("🧩 Missing chunks: ${COUNT}", {
|
||||
text: $msg("Unreadable on this device; ${COUNT} referenced chunks are missing or deleted", {
|
||||
COUNT: `${missing.length}`,
|
||||
}),
|
||||
cls: "sls-repair-metric mod-warning",
|
||||
});
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("📦 DB: recorded ${RECORDED} B · decoded unavailable", {
|
||||
RECORDED: `${comparison.recordedSize}`,
|
||||
}),
|
||||
cls: "sls-repair-metric",
|
||||
cls: "mod-warning",
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
this.createEl(revisionEl, "code", {
|
||||
@@ -482,196 +243,28 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
comparison.vaultSize !== null &&
|
||||
comparison.databaseToVaultSizeDifference !== null
|
||||
) {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B", {
|
||||
VAULT: `${comparison.vaultSize}`,
|
||||
DIFFERENCE: formatSigned(
|
||||
comparison.databaseToVaultSizeDifference
|
||||
),
|
||||
}),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
}
|
||||
if (
|
||||
comparison.vaultMtime !== null &&
|
||||
comparison.timestampDifferenceMs !== null
|
||||
) {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg(
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
|
||||
{
|
||||
DATABASE_TIME: new Date(
|
||||
comparison.databaseMtime
|
||||
).toLocaleString(),
|
||||
VAULT_TIME: new Date(
|
||||
comparison.vaultMtime
|
||||
).toLocaleString(),
|
||||
DIFFERENCE: formatSigned(
|
||||
comparison.timestampDifferenceMs
|
||||
),
|
||||
RELATION: timestampRelationLabel(
|
||||
comparison.timestampRelation
|
||||
),
|
||||
}
|
||||
),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
}
|
||||
if (revision.contentMatchesStorage === true) {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("✅ Matches Vault"),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
this.createEl(revisionEl, "div", { text: $msg("Matches the current Vault file") });
|
||||
} else if (revision.contentMatchesStorage === false) {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("⚠️ Differs from Vault"),
|
||||
cls: "sls-repair-metric mod-warning",
|
||||
});
|
||||
this.createEl(revisionEl, "div", { text: $msg("Differs from the current Vault file") });
|
||||
}
|
||||
|
||||
const policy = getFileRepairRevisionActions(inspection, revision);
|
||||
const revisionActions: RepairMenuAction[] = [];
|
||||
if (metadata.revision && policy.compareWithVault) {
|
||||
revisionActions.push({
|
||||
title: $msg("Compare with Vault"),
|
||||
run: async () => {
|
||||
await openRevisionComparison(path, metadata.revision!);
|
||||
},
|
||||
if (!metadata.deleted && !revision.contentReadable && metadata.revision) {
|
||||
const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" });
|
||||
addActionButton(actions, $msg("Retry reading revision"), async () => {
|
||||
const loaded = await retryReadFileDatabaseRevision(this.core, path, metadata.revision!);
|
||||
Logger(
|
||||
loaded
|
||||
? `Revision ${metadata.revision} of ${path} is readable after retry`
|
||||
: `Revision ${metadata.revision} of ${path} remains unreadable`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.applyRevisionToVault) {
|
||||
revisionActions.push({
|
||||
title: $msg("Apply this revision to Vault"),
|
||||
run: async () => {
|
||||
if (await this.core.storageAccess.isExistsIncludeHidden(path)) {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.",
|
||||
{
|
||||
REVISION: metadata.revision!,
|
||||
FILE: path,
|
||||
}
|
||||
),
|
||||
{
|
||||
title: $msg("Apply database revision to Vault"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await runMutation(
|
||||
`Apply database revision ${metadata.revision} to the Vault`,
|
||||
() =>
|
||||
applyRevisionToStorage(
|
||||
path,
|
||||
metadata.revision!,
|
||||
true
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.markAsVaultRevision) {
|
||||
revisionActions.push({
|
||||
title: $msg("Mark this revision as the Vault version"),
|
||||
run: async () => {
|
||||
await runMutation(
|
||||
`Mark database revision ${metadata.revision} as the Vault version`,
|
||||
() =>
|
||||
storeStorageOnRevision(
|
||||
path,
|
||||
metadata.revision!,
|
||||
false
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.storeVaultOnBranch) {
|
||||
revisionActions.push({
|
||||
title: $msg("Store Vault file as a child of this revision"),
|
||||
run: async () => {
|
||||
await runMutation(
|
||||
`Store the Vault file on database revision ${metadata.revision}`,
|
||||
() =>
|
||||
storeStorageOnRevision(
|
||||
path,
|
||||
metadata.revision!
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.applyLogicalDeletionToVault) {
|
||||
revisionActions.push({
|
||||
title: $msg("Apply logical deletion to Vault"),
|
||||
warning: true,
|
||||
run: async () => {
|
||||
if (await this.core.storageAccess.isExistsIncludeHidden(path)) {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.",
|
||||
{
|
||||
REVISION: metadata.revision!,
|
||||
FILE: path,
|
||||
}
|
||||
),
|
||||
{
|
||||
title: $msg("Apply logical deletion to Vault"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await runMutation(
|
||||
`Apply logical deletion ${metadata.revision} to the Vault`,
|
||||
() =>
|
||||
applyRevisionToStorage(
|
||||
path,
|
||||
metadata.revision!,
|
||||
true
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.retryRevision) {
|
||||
revisionActions.push({
|
||||
title: $msg("Retry reading revision"),
|
||||
run: async () => {
|
||||
const loaded = await retryReadFileDatabaseRevision(
|
||||
this.core,
|
||||
path,
|
||||
metadata.revision!
|
||||
);
|
||||
Logger(
|
||||
loaded
|
||||
? `Revision ${metadata.revision} of ${path} is readable after retry`
|
||||
: `Revision ${metadata.revision} of ${path} remains unreadable`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.discardBranch) {
|
||||
revisionActions.push(discardLiveBranchAction(metadata.revision));
|
||||
}
|
||||
if (metadata.revision && policy.discardRevision) {
|
||||
revisionActions.push({
|
||||
title: $msg("Discard unreadable revision"),
|
||||
warning: true,
|
||||
run: async () => {
|
||||
addActionButton(
|
||||
actions,
|
||||
$msg("Discard unreadable revision"),
|
||||
async () => {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
@@ -700,54 +293,55 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
true
|
||||
);
|
||||
}
|
||||
addActionMenu(
|
||||
revisionMenuHost,
|
||||
$msg("More actions for revision ${REVISION}", {
|
||||
REVISION: metadata.revision ?? $msg("Unknown revision"),
|
||||
}),
|
||||
revisionActions
|
||||
);
|
||||
};
|
||||
revisions.forEach(addRevision);
|
||||
|
||||
for (const revision of information.database.unavailableConflictRevisions) {
|
||||
const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" });
|
||||
const revisionHeader = this.createEl(revisionEl, "div", {
|
||||
cls: "sls-repair-header",
|
||||
});
|
||||
this.createEl(revisionHeader, "div", {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("${ROLE}: ${REVISION}", {
|
||||
ROLE: $msg("Conflict revision"),
|
||||
REVISION: revision,
|
||||
}),
|
||||
cls: "sls-repair-revision-title",
|
||||
});
|
||||
const revisionMenuHost = this.createEl(revisionHeader, "div");
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("Revision metadata is unavailable on this device"),
|
||||
cls: "mod-warning",
|
||||
});
|
||||
addActionMenu(
|
||||
revisionMenuHost,
|
||||
$msg("More actions for revision ${REVISION}", {
|
||||
REVISION: revision,
|
||||
}),
|
||||
[
|
||||
{
|
||||
title: $msg("Retry reading revision"),
|
||||
run: async () => {
|
||||
await retryReadFileDatabaseRevision(
|
||||
this.core,
|
||||
path,
|
||||
revision
|
||||
);
|
||||
await refresh();
|
||||
},
|
||||
},
|
||||
discardLiveBranchAction(revision),
|
||||
]
|
||||
const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" });
|
||||
addActionButton(actions, $msg("Retry reading revision"), async () => {
|
||||
await retryReadFileDatabaseRevision(this.core, path, revision);
|
||||
await refresh();
|
||||
});
|
||||
addActionButton(
|
||||
actions,
|
||||
$msg("Discard unreadable revision"),
|
||||
async () => {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.",
|
||||
{
|
||||
REVISION: revision,
|
||||
FILE: path,
|
||||
}
|
||||
),
|
||||
{
|
||||
title: $msg("Discard unreadable revision"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
await discardUnreadableLiveRevision(this.core, path, revision);
|
||||
await refresh();
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -771,41 +365,45 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
}
|
||||
|
||||
const winner = revisions.find(({ role }) => role === "winner");
|
||||
const fileActions: RepairMenuAction[] = [];
|
||||
if (winner?.loadedEntry) {
|
||||
const actions = this.createEl(card, "div", { cls: "sls-repair-actions" });
|
||||
if (winner?.loadedEntry && information.storage.exists) {
|
||||
const winnerEntry = winner.loadedEntry;
|
||||
fileActions.push({
|
||||
title: $msg("Show revision history"),
|
||||
run: () => {
|
||||
eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, {
|
||||
file: path as FilePathWithPrefix,
|
||||
fileOnDB: winnerEntry,
|
||||
});
|
||||
},
|
||||
addActionButton(actions, $msg("Show revision history"), () => {
|
||||
eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, {
|
||||
file: path as FilePathWithPrefix,
|
||||
fileOnDB: winnerEntry,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (information.storage.exists && !information.database.exists) {
|
||||
fileActions.push({
|
||||
title: $msg("Store Vault file as a new local database document"),
|
||||
run: async () => {
|
||||
await runMutation(
|
||||
"Store the Vault file as a new local database document",
|
||||
() => storeStorageInDatabase(path)
|
||||
);
|
||||
},
|
||||
if (
|
||||
information.storage.exists &&
|
||||
information.database.conflictCount === 0 &&
|
||||
(!winner || winner.contentReadable)
|
||||
) {
|
||||
addActionButton(actions, $msg("Use Vault file in local database"), async () => {
|
||||
if (!(await storeStorageInDatabase(path))) {
|
||||
Logger(`Failed to store the Vault file in the local database: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
fileActions.push({
|
||||
title: $msg("Copy database information"),
|
||||
run: async () => {
|
||||
await copyFileDatabaseInfo(this.core, path);
|
||||
},
|
||||
if (
|
||||
!information.storage.exists &&
|
||||
information.database.conflictCount === 0 &&
|
||||
winner?.loadedEntry
|
||||
) {
|
||||
addActionButton(actions, $msg("Restore database winner to Vault"), async () => {
|
||||
if (!(await applyWinnerToStorage(path, winner))) {
|
||||
Logger(`Failed to restore the database winner to the Vault: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
addActionButton(actions, $msg("Copy database information"), async () => {
|
||||
await copyFileDatabaseInfo(this.core, path);
|
||||
});
|
||||
addActionMenu(
|
||||
fileMenuHost,
|
||||
$msg("More actions for ${FILE}", { FILE: path }),
|
||||
fileActions
|
||||
);
|
||||
};
|
||||
|
||||
new Setting(paneEl)
|
||||
@@ -824,20 +422,47 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
})
|
||||
);
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Inspect conflicts and file/database differences"))
|
||||
.setName("Resolve All conflicted files by the newer one")
|
||||
.setDesc(
|
||||
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one."
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Resolve All")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable."
|
||||
),
|
||||
{
|
||||
title: $msg("Resolve all conflicts by the newest version"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Verify and repair all files"))
|
||||
.setDesc(
|
||||
$msg(
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision."
|
||||
"Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision."
|
||||
)
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText($msg("Begin inspection"))
|
||||
.setButtonText($msg("Verify all"))
|
||||
.setDisabled(false)
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
resultArea.replaceChildren();
|
||||
Logger("Start inspecting file/database state", LOG_LEVEL_NOTICE, "verify");
|
||||
Logger("Start verifying all files", LOG_LEVEL_NOTICE, "verify");
|
||||
this.core.localDatabase.clearCaches();
|
||||
const allPaths = await collectFileDatabaseInfoPaths(this.core);
|
||||
let i = 0;
|
||||
@@ -894,32 +519,6 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
// Logger(`${i}/${files.length}\n`, LOG_LEVEL_NOTICE, "verify-processed");
|
||||
})
|
||||
);
|
||||
new Setting(paneEl)
|
||||
.setName("Resolve All conflicted files by the newer one")
|
||||
.setDesc(
|
||||
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one."
|
||||
)
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Resolve All")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable."
|
||||
),
|
||||
{
|
||||
title: $msg("Resolve all conflicts by the newest version"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
})
|
||||
);
|
||||
new Setting(paneEl)
|
||||
.setName("Check and convert non-path-obfuscated files")
|
||||
.setDesc("")
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
platform: {
|
||||
isMobile: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
|
||||
ObsHttpHandler: class {},
|
||||
}));
|
||||
|
||||
vi.mock("./ObsidianConfirm", () => ({
|
||||
ObsidianConfirm: class {},
|
||||
}));
|
||||
|
||||
import { ObsidianAPIService } from "./ObsidianAPIService";
|
||||
import type { ObsidianServiceContext } from "./ObsidianServiceContext";
|
||||
|
||||
function createService(workspace: Record<string, unknown>, isMobile = false): ObsidianAPIService {
|
||||
return new ObsidianAPIService({
|
||||
app: { workspace, isMobile },
|
||||
} as unknown as ObsidianServiceContext);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.platform.isMobile = false;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
it("keeps the status view in the right leaf on mobile", async () => {
|
||||
mocks.platform.isMobile = true;
|
||||
const rightLeaf = {
|
||||
setViewState: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const workspace = {
|
||||
getLeavesOfType: vi.fn(() => []),
|
||||
getLeaf: vi.fn(),
|
||||
getRightLeaf: vi.fn(() => rightLeaf),
|
||||
revealLeaf: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = createService(workspace, true);
|
||||
|
||||
expect(service.isMobile()).toBe(true);
|
||||
await service.showWindowOnRight("p2p-status");
|
||||
|
||||
expect(workspace.getLeavesOfType).toHaveBeenCalledWith("p2p-status");
|
||||
expect(workspace.getRightLeaf).toHaveBeenCalledWith(false);
|
||||
expect(workspace.getLeaf).not.toHaveBeenCalled();
|
||||
expect(rightLeaf.setViewState).toHaveBeenCalledWith({
|
||||
type: "p2p-status",
|
||||
active: false,
|
||||
});
|
||||
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,6 @@ export type DiscardUnreadableRevisionResult =
|
||||
| "no-longer-live"
|
||||
| "revision-is-readable";
|
||||
|
||||
export type DiscardLiveBranchResult = "discarded" | "failed" | "no-longer-live" | "only-live-revision";
|
||||
|
||||
export async function inspectFileRepair(core: FileRepairCore, path: string): Promise<FileRepairInspection> {
|
||||
const information = await inspectFileDatabaseInfo(core, path);
|
||||
const storageContent = information.storage.exists
|
||||
@@ -64,12 +62,12 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro
|
||||
}
|
||||
|
||||
const winner = revisions.find(({ role }) => role === "winner");
|
||||
const winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted;
|
||||
const databaseAndStorageDiffer =
|
||||
information.storage.exists !== winnerRepresentsStoredFile ||
|
||||
information.storage.exists !== information.database.exists ||
|
||||
(information.storage.exists &&
|
||||
winnerRepresentsStoredFile &&
|
||||
winner.contentMatchesStorage === false);
|
||||
winner !== undefined &&
|
||||
(winner.metadata.deleted || winner.contentMatchesStorage === false)) ||
|
||||
(!information.storage.exists && winner !== undefined && !winner.metadata.deleted);
|
||||
const unreadableLiveRevision =
|
||||
information.database.unavailableConflictRevisions.length > 0 ||
|
||||
revisions.some(({ contentReadable }) => !contentReadable);
|
||||
@@ -109,24 +107,3 @@ export async function discardUnreadableLiveRevision(
|
||||
const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision);
|
||||
return deleted ? "discarded" : "failed";
|
||||
}
|
||||
|
||||
export async function discardLiveBranch(
|
||||
core: FileRepairCore,
|
||||
path: string,
|
||||
revision: string
|
||||
): Promise<DiscardLiveBranchResult> {
|
||||
const latest = await inspectFileDatabaseInfo(core, path);
|
||||
const liveRevisions = [
|
||||
latest.database.currentRevision,
|
||||
...latest.database.conflictRevisions,
|
||||
].filter((candidate): candidate is string => candidate !== null);
|
||||
if (!liveRevisions.includes(revision)) {
|
||||
return "no-longer-live";
|
||||
}
|
||||
if (liveRevisions.length < 2) {
|
||||
return "only-live-revision";
|
||||
}
|
||||
|
||||
const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision);
|
||||
return deleted ? "discarded" : "failed";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discardLiveBranch,
|
||||
discardUnreadableLiveRevision,
|
||||
inspectFileRepair,
|
||||
} from "./fileRepair";
|
||||
@@ -17,7 +16,6 @@ function createCore() {
|
||||
size: 7,
|
||||
type: "plain",
|
||||
children: ["h:current"],
|
||||
deleted: false,
|
||||
eden: {},
|
||||
};
|
||||
const conflict = {
|
||||
@@ -120,29 +118,6 @@ describe("file repair inspection", () => {
|
||||
expect(inspection.requiresAttention).toBe(true);
|
||||
});
|
||||
|
||||
it("omits a logical deletion which already matches an absent Vault file", async () => {
|
||||
const { core, current } = createCore();
|
||||
current.deleted = true;
|
||||
current._conflicts = [];
|
||||
current.children = [];
|
||||
core.storageAccess.isExistsIncludeHidden.mockResolvedValue(false);
|
||||
core.storageAccess.statHidden.mockResolvedValue(null as never);
|
||||
|
||||
const inspection = await inspectFileRepair(core as never, "note.md");
|
||||
|
||||
expect(inspection.revisions).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "winner",
|
||||
contentReadable: true,
|
||||
metadata: expect.objectContaining({
|
||||
deleted: true,
|
||||
revision: "3-current",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(inspection.requiresAttention).toBe(false);
|
||||
});
|
||||
|
||||
it("rechecks liveness and readability before discarding an exact revision", async () => {
|
||||
const { core, deleteRevisionFromDB } = createCore();
|
||||
|
||||
@@ -194,35 +169,4 @@ describe("file repair inspection", () => {
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards an exact readable winner while another live branch remains", async () => {
|
||||
const { core, deleteRevisionFromDB } = createCore();
|
||||
|
||||
await expect(
|
||||
discardLiveBranch(core as never, "note.md", "3-current")
|
||||
).resolves.toBe("discarded");
|
||||
|
||||
expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "3-current");
|
||||
});
|
||||
|
||||
it("refuses to discard the only live branch", async () => {
|
||||
const { core, current, deleteRevisionFromDB } = createCore();
|
||||
current._conflicts = [];
|
||||
|
||||
await expect(
|
||||
discardLiveBranch(core as never, "note.md", "3-current")
|
||||
).resolves.toBe("only-live-revision");
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to discard a branch which is no longer live", async () => {
|
||||
const { core, deleteRevisionFromDB } = createCore();
|
||||
|
||||
await expect(
|
||||
discardLiveBranch(core as never, "note.md", "1-stale")
|
||||
).resolves.toBe("no-longer-live");
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import {
|
||||
BASE_IS_NEW,
|
||||
EVEN,
|
||||
TARGET_IS_NEW,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import {
|
||||
compareMTime,
|
||||
readAsBlob,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type {
|
||||
FileRepairInspection,
|
||||
FileRepairRevision,
|
||||
} from "./fileRepair";
|
||||
|
||||
export type FileRepairRevisionActions = {
|
||||
compareWithVault: boolean;
|
||||
applyRevisionToVault: boolean;
|
||||
markAsVaultRevision: boolean;
|
||||
storeVaultOnBranch: boolean;
|
||||
applyLogicalDeletionToVault: boolean;
|
||||
retryRevision: boolean;
|
||||
discardBranch: boolean;
|
||||
discardRevision: boolean;
|
||||
};
|
||||
|
||||
export type FileRepairTimestampRelation =
|
||||
| "vault-newer"
|
||||
| "database-newer"
|
||||
| "same-window"
|
||||
| "unavailable";
|
||||
|
||||
export type FileRepairRevisionComparison = {
|
||||
recordedSize: number;
|
||||
decodedSize: number | null;
|
||||
recordedToDecodedSizeDifference: number | null;
|
||||
vaultSize: number | null;
|
||||
databaseToVaultSizeDifference: number | null;
|
||||
databaseMtime: number;
|
||||
vaultMtime: number | null;
|
||||
timestampDifferenceMs: number | null;
|
||||
timestampRelation: FileRepairTimestampRelation;
|
||||
};
|
||||
|
||||
export function getFileRepairRevisionActions(
|
||||
inspection: FileRepairInspection,
|
||||
revision: FileRepairRevision
|
||||
): FileRepairRevisionActions {
|
||||
const storageExists = inspection.information.storage.exists;
|
||||
const hasRevision = revision.metadata.revision !== null;
|
||||
const readableFileRevision =
|
||||
!revision.metadata.deleted &&
|
||||
revision.contentReadable &&
|
||||
revision.loadedEntry !== false;
|
||||
const matchesVault = storageExists && revision.contentMatchesStorage === true;
|
||||
const hasConflictBranches = inspection.information.database.conflictCount > 0;
|
||||
|
||||
return {
|
||||
compareWithVault:
|
||||
readableFileRevision &&
|
||||
storageExists &&
|
||||
revision.contentMatchesStorage === false &&
|
||||
isPlainText(inspection.information.path),
|
||||
applyRevisionToVault:
|
||||
hasRevision &&
|
||||
readableFileRevision &&
|
||||
(!storageExists || revision.contentMatchesStorage !== true),
|
||||
markAsVaultRevision:
|
||||
hasRevision &&
|
||||
readableFileRevision &&
|
||||
matchesVault,
|
||||
storeVaultOnBranch:
|
||||
hasRevision &&
|
||||
storageExists &&
|
||||
revision.contentMatchesStorage !== true,
|
||||
applyLogicalDeletionToVault:
|
||||
hasRevision &&
|
||||
revision.metadata.deleted &&
|
||||
storageExists,
|
||||
retryRevision:
|
||||
hasRevision &&
|
||||
!revision.metadata.deleted &&
|
||||
!revision.contentReadable,
|
||||
discardBranch: hasRevision && hasConflictBranches,
|
||||
discardRevision:
|
||||
hasRevision &&
|
||||
!hasConflictBranches &&
|
||||
!revision.metadata.deleted &&
|
||||
!revision.contentReadable,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFileRepairRevisionComparison(
|
||||
inspection: FileRepairInspection,
|
||||
revision: FileRepairRevision
|
||||
): FileRepairRevisionComparison {
|
||||
const decodedSize =
|
||||
revision.loadedEntry === false
|
||||
? null
|
||||
: readAsBlob(revision.loadedEntry).size;
|
||||
const vaultSize =
|
||||
inspection.information.storage.exists
|
||||
? (inspection.information.storage.size ?? null)
|
||||
: null;
|
||||
const databaseMtime = revision.metadata.mtime;
|
||||
const vaultMtime =
|
||||
inspection.information.storage.exists
|
||||
? (inspection.information.storage.mtime ?? null)
|
||||
: null;
|
||||
const timestampDifferenceMs =
|
||||
databaseMtime > 0 && vaultMtime !== null && vaultMtime > 0
|
||||
? vaultMtime - databaseMtime
|
||||
: null;
|
||||
let timestampRelation: FileRepairTimestampRelation = "unavailable";
|
||||
if (timestampDifferenceMs !== null) {
|
||||
const comparison = compareMTime(vaultMtime!, databaseMtime);
|
||||
timestampRelation =
|
||||
comparison === EVEN
|
||||
? "same-window"
|
||||
: comparison === BASE_IS_NEW
|
||||
? "vault-newer"
|
||||
: comparison === TARGET_IS_NEW
|
||||
? "database-newer"
|
||||
: "unavailable";
|
||||
}
|
||||
|
||||
return {
|
||||
recordedSize: revision.metadata.recordedSize,
|
||||
decodedSize,
|
||||
recordedToDecodedSizeDifference:
|
||||
decodedSize === null
|
||||
? null
|
||||
: decodedSize - revision.metadata.recordedSize,
|
||||
vaultSize,
|
||||
databaseToVaultSizeDifference:
|
||||
decodedSize === null || vaultSize === null
|
||||
? null
|
||||
: vaultSize - decodedSize,
|
||||
databaseMtime,
|
||||
vaultMtime,
|
||||
timestampDifferenceMs,
|
||||
timestampRelation,
|
||||
};
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { FileRepairInspection, FileRepairRevision } from "./fileRepair";
|
||||
import {
|
||||
getFileRepairRevisionActions,
|
||||
getFileRepairRevisionComparison,
|
||||
} from "./fileRepairPresentation";
|
||||
|
||||
function createInspection(
|
||||
revision: Partial<FileRepairRevision> = {},
|
||||
storage: { exists: boolean; size?: number; mtime?: number } = {
|
||||
exists: true,
|
||||
size: 12,
|
||||
mtime: 5_500,
|
||||
}
|
||||
): { inspection: FileRepairInspection; revision: FileRepairRevision } {
|
||||
const completeRevision = {
|
||||
role: "conflict",
|
||||
metadata: {
|
||||
documentId: "f:note",
|
||||
revision: "2-conflict",
|
||||
current: false,
|
||||
deleted: false,
|
||||
storageType: "plain",
|
||||
storageLayout: "chunked",
|
||||
ctime: 1,
|
||||
mtime: 2_000,
|
||||
recordedSize: 9,
|
||||
revisionHistory: [],
|
||||
chunkReferences: 0,
|
||||
uniqueChunkReferences: 0,
|
||||
embeddedChunkReferences: 0,
|
||||
locallyStoredChunkReferences: 0,
|
||||
contentAvailableLocally: true,
|
||||
chunks: [],
|
||||
},
|
||||
contentReadable: true,
|
||||
contentMatchesStorage: false,
|
||||
loadedEntry: {
|
||||
_id: "f:note",
|
||||
_rev: "2-conflict",
|
||||
path: "note.md",
|
||||
ctime: 1,
|
||||
mtime: 2_000,
|
||||
size: 9,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: [],
|
||||
eden: {},
|
||||
data: "content",
|
||||
},
|
||||
...revision,
|
||||
} as FileRepairRevision;
|
||||
const inspection = {
|
||||
information: {
|
||||
path: "note.md",
|
||||
databasePath: "note.md" as FilePathWithPrefix,
|
||||
storage,
|
||||
database: {
|
||||
source: "local database on this device",
|
||||
remoteQueried: false,
|
||||
exists: true,
|
||||
currentRevision: "3-winner",
|
||||
conflictCount: 1,
|
||||
conflictRevisions: ["2-conflict"],
|
||||
unavailableConflictRevisions: [],
|
||||
revisions: [],
|
||||
mergeBases: [],
|
||||
},
|
||||
},
|
||||
revisions: [completeRevision],
|
||||
requiresAttention: true,
|
||||
} satisfies FileRepairInspection;
|
||||
return { inspection, revision: completeRevision };
|
||||
}
|
||||
|
||||
describe("file repair presentation", () => {
|
||||
it("offers both reconciliation directions for a readable differing revision", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toEqual({
|
||||
compareWithVault: true,
|
||||
applyRevisionToVault: true,
|
||||
markAsVaultRevision: false,
|
||||
storeVaultOnBranch: true,
|
||||
applyLogicalDeletionToVault: false,
|
||||
retryRevision: false,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks an exact matching revision without creating another child", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
contentMatchesStorage: true,
|
||||
});
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
compareWithVault: false,
|
||||
applyRevisionToVault: false,
|
||||
markAsVaultRevision: true,
|
||||
storeVaultOnBranch: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer a text comparison for a binary file", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
inspection.information.path = "image.png";
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
compareWithVault: false,
|
||||
applyRevisionToVault: true,
|
||||
storeVaultOnBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers explicit deletion or branch extension for a logical deletion", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
deleted: true,
|
||||
},
|
||||
contentReadable: true,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
});
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
applyRevisionToVault: false,
|
||||
storeVaultOnBranch: true,
|
||||
applyLogicalDeletionToVault: true,
|
||||
retryRevision: false,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers retry, discard, and branch extension for an unreadable live revision", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
contentReadable: false,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
});
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
compareWithVault: false,
|
||||
applyRevisionToVault: false,
|
||||
markAsVaultRevision: false,
|
||||
storeVaultOnBranch: true,
|
||||
retryRevision: true,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the existing unreadable-leaf escape hatch when there is no conflict branch", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
role: "winner",
|
||||
contentReadable: false,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
});
|
||||
inspection.information.database.conflictCount = 0;
|
||||
inspection.information.database.conflictRevisions = [];
|
||||
inspection.information.database.currentRevision = revision.metadata.revision;
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
discardRevision: true,
|
||||
discardBranch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer a storage action for a matching absent logical deletion", () => {
|
||||
const { inspection, revision } = createInspection(
|
||||
{
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
deleted: true,
|
||||
},
|
||||
contentReadable: true,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
},
|
||||
{ exists: false }
|
||||
);
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
applyLogicalDeletionToVault: false,
|
||||
storeVaultOnBranch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports recorded, decoded, Vault-size, and timestamp differences", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
|
||||
expect(getFileRepairRevisionComparison(inspection, revision)).toEqual({
|
||||
recordedSize: 9,
|
||||
decodedSize: 7,
|
||||
recordedToDecodedSizeDifference: -2,
|
||||
vaultSize: 12,
|
||||
databaseToVaultSizeDifference: 5,
|
||||
databaseMtime: 2_000,
|
||||
vaultMtime: 5_500,
|
||||
timestampDifferenceMs: 3_500,
|
||||
timestampRelation: "vault-newer",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the same two-second timestamp comparison window as synchronisation", () => {
|
||||
const { inspection, revision } = createInspection(
|
||||
{
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
mtime: 3_001,
|
||||
},
|
||||
},
|
||||
{
|
||||
exists: true,
|
||||
size: 12,
|
||||
mtime: 3_999,
|
||||
}
|
||||
);
|
||||
|
||||
expect(getFileRepairRevisionComparison(inspection, revision)).toMatchObject({
|
||||
timestampDifferenceMs: 998,
|
||||
timestampRelation: "same-window",
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
-56
@@ -612,62 +612,6 @@ body.is-mobile .livesync-compatibility-review-notice {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.sls-repair-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sls-repair-header > :first-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sls-repair-header h6 {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sls-repair-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-4-2);
|
||||
margin-top: var(--size-4-1);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.sls-repair-status-ok {
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
.sls-repair-status-warning {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.sls-repair-metric {
|
||||
margin-top: var(--size-4-1);
|
||||
font-size: var(--font-ui-smaller);
|
||||
line-height: var(--line-height-tight);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sls-repair-action-menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--clickable-icon-size);
|
||||
width: var(--clickable-icon-size);
|
||||
height: var(--clickable-icon-size);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sls-repair-action-menu .svg-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.sls-repair-revision {
|
||||
margin-top: var(--size-4-2);
|
||||
padding: var(--size-4-2);
|
||||
@@ -692,6 +636,13 @@ body.is-mobile .livesync-compatibility-review-notice {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.sls-repair-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-4-2);
|
||||
margin-top: var(--size-4-2);
|
||||
}
|
||||
|
||||
/* Diff navigation */
|
||||
.diff-options-row {
|
||||
display: flex;
|
||||
|
||||
@@ -7,12 +7,12 @@ The generic application discovery, isolated-vault, plug-in installation, process
|
||||
The current smoke runner verifies the launch path and the loaded plug-in's Service Context composition:
|
||||
|
||||
1. create a temporary vault,
|
||||
2. install the built Self-hosted LiveSync plug-in artefacts,
|
||||
2. install the built Self-hosted LiveSync plug-in artifacts,
|
||||
3. launch real Obsidian,
|
||||
4. open the temporary vault through `obsidian-cli`,
|
||||
5. prepare the isolated Vault trust state and handle any Obsidian trust prompt,
|
||||
6. preserve natural plug-in loading, or complete requested pre-load work before loading the plug-in once in controlled start-up,
|
||||
7. verify through the active renderer that the plug-in is loaded,
|
||||
5. enable Obsidian community plug-ins for the temporary app profile,
|
||||
6. reload Self-hosted LiveSync through `obsidian-cli`,
|
||||
7. verify through `obsidian-cli eval` that the plug-in is loaded,
|
||||
8. observe event and translation results from the actual `ObsidianServiceContext`,
|
||||
9. verify that the Service Hub and every exposed service retain that exact Context,
|
||||
10. optionally drive a real vault or CouchDB workflow through Obsidian's own API, and
|
||||
@@ -24,8 +24,6 @@ Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/comm
|
||||
|
||||
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here.
|
||||
|
||||
When a LiveSync-owned scenario must establish application state before the plug-in's first load, pass an instance-scoped `lifecycle.beforePluginStart` callback through that wrapper. For example, the P2P pane scenario calls `setObsidianMobileTestModeBeforePluginStart()` there so LiveSync observes the mobile application state while registering its command and view. Mobile emulation reopens Obsidian's workspace layout; this helper waits for both the `is-mobile` body state and `workspace.layoutReady` before controlled loading continues. The shared package owns the controlled start-up order and guarantees that the plug-in loads once; the LiveSync scenario owns the resulting command, workspace placement, and visible UI assertions. Changing the state only after loading the plug-in is not evidence of its mobile start-up behaviour.
|
||||
|
||||
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
|
||||
|
||||
On macOS, `@vrtmrz/obsidian-test-session` keeps the generated Vault and profile below `/tmp` so Obsidian's Unix-domain CLI socket remains below the platform path limit. It also gives only the isolated Obsidian process Chromium's mock-keychain flag, preventing the empty test HOME from opening a blocking login-keychain dialogue. LiveSync's deterministic fixture selects the built-in default language so a host-language translation prompt cannot pause plug-in readiness. The case-only rename check enumerates the parent directory and compares exact spellings because an old-path lookup still resolves the renamed file on the default case-insensitive macOS filesystem.
|
||||
@@ -112,7 +110,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
|
||||
|
||||
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
|
||||
|
||||
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
|
||||
`test:e2e:obsidian:p2p-pane` starts one unconfigured CouchDB-only session and one configured P2P session. It proves that the command remains registered while the retired command, automatic pane, and unconfigured ribbon entry are absent. For the configured profile, it verifies that the ribbon and current status command reach the pane without opening it at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
|
||||
|
||||
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
|
||||
|
||||
@@ -128,8 +126,6 @@ The two-Vault workflow performs the missing-marker review once for each isolated
|
||||
|
||||
`test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise.
|
||||
|
||||
The isolated Obsidian session starts with its CouchDB settings and device-local compatibility acknowledgement already in place. This keeps the scenario focused on cross-runtime data compatibility; unconfigured start-up and visible CouchDB onboarding are covered by their dedicated workflows.
|
||||
|
||||
By default, the compatibility check runs `node src/apps/cli/dist/index.cjs`. Set `LIVESYNC_CLI_COMMAND` to test another CLI build or distribution. The value may be a quoted command line or a JSON array of executable and prefix arguments; the scenario arguments are appended without going through a shell.
|
||||
|
||||
For example, to test an executable on `PATH`:
|
||||
@@ -145,13 +141,13 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
npm run test:e2e:obsidian:cli-to-obsidian-sync
|
||||
```
|
||||
|
||||
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
|
||||
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
|
||||
|
||||
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
|
||||
|
||||
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
|
||||
|
||||
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
|
||||
`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file.
|
||||
|
||||
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
|
||||
|
||||
@@ -165,7 +161,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
|
||||
|
||||
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
|
||||
`test:e2e:obsidian:revision-repair` creates two live revisions in a temporary real Obsidian Vault, removes a chunk used only by the non-winning revision, and proves that automatic conflict checking does not discard the unreadable branch. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, and leave the revision tree unchanged when reading is retried. The scenario then verifies both the cancellation path and the explicit confirmation path for discarding that exact unreadable live revision, requires the winner to remain unchanged, and captures the repair card. It uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ vi.mock("./cli.ts", () => ({ evalObsidianJson }));
|
||||
import {
|
||||
assertE2eCompatibilityMarker,
|
||||
createE2eCouchDbPluginData,
|
||||
prepareRemote,
|
||||
waitForLiveSyncCoreReady,
|
||||
type CompatibilityMarkerState,
|
||||
} from "./liveSyncWorkflow.ts";
|
||||
@@ -78,18 +77,3 @@ describe("Real Obsidian core readiness", () => {
|
||||
expect(evalObsidianJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote fixture preparation", () => {
|
||||
it("waits for the remote Security Seed after resolving a new remote", async () => {
|
||||
evalObsidianJson.mockReset();
|
||||
evalObsidianJson.mockResolvedValueOnce({ status: "resolved", securitySeedReady: true });
|
||||
|
||||
await prepareRemote("obsidian-cli", {});
|
||||
|
||||
const evaluatedCode = String(evalObsidianJson.mock.calls[0]?.[1] ?? "");
|
||||
expect(evaluatedCode.indexOf("markRemoteResolved")).toBeLessThan(
|
||||
evaluatedCode.indexOf("ensurePBKDF2Salt")
|
||||
);
|
||||
expect(evaluatedCode).toContain("Timed out preparing the remote Security Seed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -462,7 +462,6 @@ export function assertObsidianServiceContextContract(result: ObsidianServiceCont
|
||||
}
|
||||
|
||||
export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_PREPARE_TIMEOUT_MS ?? 20000);
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
@@ -472,16 +471,8 @@ export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv):
|
||||
"const replicator=core.services.replicator.getActiveReplicator();",
|
||||
"await replicator.tryCreateRemoteDatabase(settings);",
|
||||
"await replicator.markRemoteResolved(settings);",
|
||||
`const deadline=Date.now()+${JSON.stringify(timeoutMs)};`,
|
||||
"let securitySeedReady=false;",
|
||||
"do{",
|
||||
"securitySeedReady=await replicator.ensurePBKDF2Salt(settings,false,false);",
|
||||
"if(securitySeedReady) break;",
|
||||
"await new Promise((resolve)=>setTimeout(resolve,250));",
|
||||
"}while(Date.now()<deadline);",
|
||||
"if(!securitySeedReady) throw new Error('Timed out preparing the remote Security Seed');",
|
||||
"const status=await replicator.getRemoteStatus(settings);",
|
||||
"return JSON.stringify({status,securitySeedReady});",
|
||||
"return JSON.stringify({status});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertLocatorHasMinimumTouchTarget,
|
||||
assertLocatorWithinSafeArea,
|
||||
@@ -14,20 +12,13 @@ export const desktopViewport = { width: 1024, height: 768 } as const;
|
||||
export const iPhoneSafeArea = { top: 47, right: 0, bottom: 34, left: 0 } as const;
|
||||
|
||||
type ObsidianTestApp = {
|
||||
isMobile?: boolean;
|
||||
emulateMobile?: (mobile: boolean) => void;
|
||||
plugins?: { plugins: Record<string, unknown> };
|
||||
workspace?: { layoutReady?: boolean };
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
async function applyObsidianMobileTestMode(
|
||||
port: number,
|
||||
enabled: boolean,
|
||||
timeoutMs: number,
|
||||
waitForLiveSync: boolean
|
||||
): Promise<void> {
|
||||
export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.setViewportSize(enabled ? mobileViewport : desktopViewport);
|
||||
await page.evaluate((nextEnabled) => {
|
||||
@@ -37,49 +28,17 @@ async function applyObsidianMobileTestMode(
|
||||
}
|
||||
obsidianApp.emulateMobile(nextEnabled);
|
||||
}, enabled);
|
||||
// Obsidian reopens its workspace layout when platform emulation
|
||||
// changes. Loading a controlled plug-in before that transition has
|
||||
// completed can leave the plug-in enabled but absent from the active
|
||||
// renderer.
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
({ nextEnabled, waitForLiveSync }) => {
|
||||
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
|
||||
return (
|
||||
document.body.classList.contains("is-mobile") === nextEnabled &&
|
||||
obsidianApp?.workspace?.layoutReady === true &&
|
||||
(!waitForLiveSync || obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined)
|
||||
);
|
||||
},
|
||||
{ nextEnabled: enabled, waitForLiveSync },
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
} catch (error) {
|
||||
const state = await page.evaluate(() => {
|
||||
await page.waitForFunction(
|
||||
(nextEnabled) => {
|
||||
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
|
||||
return {
|
||||
appIsMobile: obsidianApp?.isMobile ?? null,
|
||||
bodyClasses: document.body.className,
|
||||
documentReadyState: document.readyState,
|
||||
liveSyncLoaded: obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined,
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
workspaceLayoutReady: obsidianApp?.workspace?.layoutReady ?? null,
|
||||
};
|
||||
});
|
||||
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const screenshotPath = join(
|
||||
outputDirectory,
|
||||
waitForLiveSync
|
||||
? "mobile-mode-transition.failure.png"
|
||||
: "mobile-mode-before-plugin-start.failure.png"
|
||||
);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Obsidian mobile-mode transition did not settle: ${JSON.stringify(state)}; screenshot=${screenshotPath}; cause=${detail}`
|
||||
);
|
||||
}
|
||||
return (
|
||||
document.body.classList.contains("is-mobile") === nextEnabled &&
|
||||
obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined
|
||||
);
|
||||
},
|
||||
enabled,
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
await page.evaluate(
|
||||
(safeArea) => {
|
||||
for (const edge of ["top", "right", "bottom", "left"] as const) {
|
||||
@@ -93,19 +52,6 @@ async function applyObsidianMobileTestMode(
|
||||
});
|
||||
}
|
||||
|
||||
/** Enters mobile emulation before LiveSync's first load in a controlled session. */
|
||||
export async function setObsidianMobileTestModeBeforePluginStart(
|
||||
port: number,
|
||||
enabled: boolean,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
await applyObsidianMobileTestMode(port, enabled, timeoutMs, false);
|
||||
}
|
||||
|
||||
export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise<void> {
|
||||
await applyObsidianMobileTestMode(port, enabled, timeoutMs, true);
|
||||
}
|
||||
|
||||
export async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise<void> {
|
||||
const dialogue = container.locator(".modal").last();
|
||||
const closeButton = dialogue.locator(".modal-close-button");
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
/**
|
||||
* Supplies the runner-owned CouchDB fixture operations for the Security Seed
|
||||
* reconnect scenario. Production code remains responsible for fetching,
|
||||
* caching, and applying the Seed; this helper only validates the managed
|
||||
* synchronisation-parameter document, replaces the one intended field, and
|
||||
* compares document snapshots.
|
||||
*
|
||||
* Callers expose only short SHA-256 fingerprints. The original and replacement
|
||||
* Seed values must stay inside the isolated test process and must not be
|
||||
* written to diagnostics, screenshots, or machine-readable results.
|
||||
*/
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { CouchDbDocument } from "./couchdb.ts";
|
||||
|
||||
|
||||
@@ -52,36 +52,4 @@ describe("LiveSync real-Obsidian session", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards instance-scoped lifecycle hooks and the selected plug-in start mode", async () => {
|
||||
const beforePluginStart = vi.fn(async () => undefined);
|
||||
const vault = {
|
||||
path: "/tmp/mobile-vault",
|
||||
statePath: "/tmp/mobile-state",
|
||||
name: "mobile-vault",
|
||||
id: "mobile-vault-id",
|
||||
homePath: "/tmp/mobile-state/home",
|
||||
xdgConfigPath: "/tmp/mobile-state/xdg-config",
|
||||
xdgCachePath: "/tmp/mobile-state/xdg-cache",
|
||||
xdgDataPath: "/tmp/mobile-state/xdg-data",
|
||||
userDataPath: "/tmp/mobile-state/user-data",
|
||||
processMarker: "/tmp/mobile-state",
|
||||
dispose: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await startObsidianLiveSyncSession({
|
||||
binary: "/Applications/Obsidian",
|
||||
cliBinary: "obsidian-cli",
|
||||
vault,
|
||||
pluginStartup: "controlled",
|
||||
lifecycle: { beforePluginStart },
|
||||
});
|
||||
|
||||
expect(startObsidianPluginSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lifecycle: { beforePluginStart },
|
||||
pluginStartup: "controlled",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
startObsidianPluginSession,
|
||||
type ObsidianPluginSession,
|
||||
type ObsidianPluginSessionLifecycle,
|
||||
type ObsidianPluginStartupMode,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
|
||||
import type { TemporaryVault } from "./vault.ts";
|
||||
|
||||
export type ObsidianLiveSyncSession = ObsidianPluginSession;
|
||||
@@ -16,8 +11,6 @@ export type StartObsidianLiveSyncSessionOptions = {
|
||||
startupGraceMs?: number;
|
||||
pluginData?: Record<string, unknown>;
|
||||
localStorageEntries?: Readonly<Record<string, string>>;
|
||||
pluginStartup?: ObsidianPluginStartupMode;
|
||||
lifecycle?: ObsidianPluginSessionLifecycle;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
@@ -33,8 +26,6 @@ export async function startObsidianLiveSyncSession(
|
||||
startupGraceMs: options.startupGraceMs,
|
||||
pluginData: options.pluginData,
|
||||
localStorageEntries: options.localStorageEntries,
|
||||
pluginStartup: options.pluginStartup,
|
||||
lifecycle: options.lifecycle,
|
||||
env: options.env,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
configureCouchDb,
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
@@ -312,23 +311,25 @@ async function main(): Promise<void> {
|
||||
cliBinary: obsidianCli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: createE2eCouchDbPluginData(
|
||||
{
|
||||
uri: couchDb.uri,
|
||||
username: couchDb.username,
|
||||
password: couchDb.password,
|
||||
dbName,
|
||||
},
|
||||
{
|
||||
encrypt: true,
|
||||
passphrase: e2eePassphrase,
|
||||
usePathObfuscation: true,
|
||||
E2EEAlgorithm: "v2",
|
||||
}
|
||||
),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv);
|
||||
await configureCouchDb(
|
||||
obsidianCli.binary,
|
||||
session.cliEnv,
|
||||
{
|
||||
uri: couchDb.uri,
|
||||
username: couchDb.username,
|
||||
password: couchDb.password,
|
||||
dbName,
|
||||
},
|
||||
{
|
||||
encrypt: true,
|
||||
passphrase: e2eePassphrase,
|
||||
usePathObfuscation: true,
|
||||
E2EEAlgorithm: "v2",
|
||||
}
|
||||
);
|
||||
await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv);
|
||||
await prepareRemote(obsidianCli.binary, session.cliEnv);
|
||||
await pushLocalChanges(obsidianCli.binary, session.cliEnv);
|
||||
|
||||
|
||||
@@ -765,39 +765,18 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
for (const label of [
|
||||
"Write logs into the file",
|
||||
"Recreate chunks for current Vault files",
|
||||
"Inspect conflicts and file/database differences",
|
||||
"Resolve All conflicted files by the newer one",
|
||||
"Verify and repair all files",
|
||||
]) {
|
||||
await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
const settingNames = await liveSyncSettings.locator(".setting-item-name").allTextContents();
|
||||
const recreateIndex = settingNames.findIndex((name) =>
|
||||
name.includes("Recreate chunks for current Vault files")
|
||||
);
|
||||
const inspectIndex = settingNames.findIndex((name) =>
|
||||
name.includes("Inspect conflicts and file/database differences")
|
||||
);
|
||||
const resolveIndex = settingNames.findIndex((name) =>
|
||||
name.includes("Resolve All conflicted files by the newer one")
|
||||
);
|
||||
if (
|
||||
recreateIndex === -1 ||
|
||||
inspectIndex === -1 ||
|
||||
resolveIndex === -1 ||
|
||||
!(recreateIndex < inspectIndex && inspectIndex < resolveIndex)
|
||||
) {
|
||||
throw new Error(
|
||||
"Recovery actions are not ordered from chunk recreation through inspection to bulk conflict resolution"
|
||||
);
|
||||
}
|
||||
await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await liveSyncSettings.getByRole("button", { name: "Begin inspection", exact: true }).waitFor({
|
||||
await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
@@ -918,7 +897,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
await page
|
||||
.locator(".sls-setting:visible")
|
||||
.last()
|
||||
.getByRole("button", { name: "Begin inspection", exact: true })
|
||||
.getByRole("button", { name: "Verify all", exact: true })
|
||||
.click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
/**
|
||||
* Verifies one complete Object Storage upload from a real Obsidian Vault,
|
||||
* through LiveSync's local database and Journal Sync, to an S3-compatible
|
||||
* service observed independently through the AWS SDK.
|
||||
*
|
||||
* The isolated Vault starts with Object Storage settings and the device-local
|
||||
* compatibility acknowledgement already in place. Unconfigured start-up is
|
||||
* intentionally inert and belongs to the onboarding scenario; compatibility
|
||||
* review and visible setup have their own dedicated workflows. Supplying those
|
||||
* prerequisites here keeps this scenario focused on the upload boundary.
|
||||
*
|
||||
* Note creation, local-database observation, one-shot synchronisation, request
|
||||
* accounting, remote-object inspection, and prefix cleanup remain in one
|
||||
* scenario so that a pass proves the same payload crossed every boundary.
|
||||
* Separate successes would not prove that those observations belonged to the
|
||||
* same upload.
|
||||
*/
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
configureObjectStorage,
|
||||
createE2eObjectStoragePluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
@@ -119,11 +100,6 @@ async function main(): Promise<void> {
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: createE2eObjectStoragePluginData({
|
||||
...objectStorage,
|
||||
bucketPrefix,
|
||||
}),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
|
||||
|
||||
@@ -1,181 +1,46 @@
|
||||
/**
|
||||
* Verifies the complete user-visible contract of the P2P status pane in real
|
||||
* Obsidian: a configured CouchDB-only Vault with no P2P profile is not
|
||||
* presented with P2P controls, while configured P2P devices can deliberately
|
||||
* open the current pane in the appropriate workspace area.
|
||||
*
|
||||
* Desktop and mobile use separate Vaults, profiles, and Obsidian processes.
|
||||
* Mobile mode is enabled before LiveSync's first load so that command and view
|
||||
* registration observe the mobile application state, and no desktop workspace
|
||||
* state can make a misplaced or restored pane appear correct.
|
||||
*
|
||||
* Command registration, automatic-opening policy, ribbon availability,
|
||||
* workspace ownership, layout, and screenshots are kept in one scenario
|
||||
* because together they describe one navigation path. Checking them in
|
||||
* isolation could miss a pane which is registered correctly but opens in the
|
||||
* wrong area, or one which is visible only because another session restored it.
|
||||
*/
|
||||
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { ConsoleMessage, Page } from "playwright";
|
||||
import type { Page } from "playwright";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts";
|
||||
import { setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000);
|
||||
|
||||
type ObsidianTestLeaf = {
|
||||
containerEl?: HTMLElement;
|
||||
view?: { getViewType?: () => string };
|
||||
};
|
||||
|
||||
type ObsidianTestWorkspace = {
|
||||
activeLeaf?: ObsidianTestLeaf;
|
||||
getLeavesOfType?: (type: string) => ObsidianTestLeaf[];
|
||||
getRightLeaf?: (split: boolean) => ObsidianTestLeaf | null;
|
||||
rightSplit?: { containerEl?: HTMLElement };
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: {
|
||||
commands?: Record<string, unknown>;
|
||||
executeCommandById(commandId: string): boolean;
|
||||
};
|
||||
isMobile?: boolean;
|
||||
plugins?: {
|
||||
plugins?: Record<
|
||||
string,
|
||||
{
|
||||
core?: {
|
||||
services?: {
|
||||
API?: {
|
||||
isMobile?: () => boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
workspace?: ObsidianTestWorkspace;
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
async function openP2PStatusPane(page: Page) {
|
||||
return await page.evaluate((commandId) => {
|
||||
const app = (globalThis as ObsidianTestGlobal).app;
|
||||
const plugin = app?.plugins?.plugins?.["obsidian-livesync"];
|
||||
return {
|
||||
opened: app?.commands?.executeCommandById(commandId) === true,
|
||||
appIsMobile: app?.isMobile ?? null,
|
||||
apiIsMobile: plugin?.core?.services?.API?.isMobile?.() ?? null,
|
||||
bodyIsMobile: document.body.classList.contains("is-mobile"),
|
||||
};
|
||||
}, "obsidian-livesync:open-p2p-server-status");
|
||||
}
|
||||
|
||||
async function collectP2PWorkspaceState(page: Page) {
|
||||
return await page.evaluate(() => {
|
||||
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
|
||||
const activeLeaf = workspace?.activeLeaf;
|
||||
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
|
||||
const rightLeaf = workspace?.getRightLeaf?.(false);
|
||||
return {
|
||||
bodyClasses: document.body.className,
|
||||
activeLeaf: {
|
||||
type: activeLeaf?.view?.getViewType?.() ?? null,
|
||||
visible: activeLeaf?.containerEl?.checkVisibility?.() ?? null,
|
||||
classes: activeLeaf?.containerEl?.className ?? null,
|
||||
},
|
||||
p2pLeaves: p2pLeaves.map((leaf) => ({
|
||||
type: leaf.view?.getViewType?.() ?? null,
|
||||
visible: leaf.containerEl?.checkVisibility?.() ?? null,
|
||||
classes: leaf.containerEl?.className ?? null,
|
||||
})),
|
||||
rightLeaf: {
|
||||
type: rightLeaf?.view?.getViewType?.() ?? null,
|
||||
visible: rightLeaf?.containerEl?.checkVisibility?.() ?? null,
|
||||
classes: rightLeaf?.containerEl?.className ?? null,
|
||||
},
|
||||
visibleP2PContents: document.querySelectorAll(
|
||||
".workspace-leaf-content[data-type='p2p-server-status']:not(.is-hidden)"
|
||||
).length,
|
||||
};
|
||||
async function openP2PStatusPane(): Promise<void> {
|
||||
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
return await page.evaluate(
|
||||
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
"obsidian-livesync:open-p2p-server-status"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function assertMobileP2PPlacement(page: Page): Promise<void> {
|
||||
const placement = await page.evaluate(() => {
|
||||
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
|
||||
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
|
||||
const rightSplit = workspace?.rightSplit?.containerEl;
|
||||
const rightLeaf = workspace?.getRightLeaf?.(false);
|
||||
return {
|
||||
p2pLeafCount: p2pLeaves.length,
|
||||
inRightSplit: p2pLeaves.some(
|
||||
(leaf) =>
|
||||
(rightSplit?.contains(leaf.containerEl ?? null) ?? false) ||
|
||||
(leaf.containerEl?.closest(".mod-right-split, .workspace-drawer.mod-right") ?? null) !== null
|
||||
),
|
||||
rightLeafType: rightLeaf?.view?.getViewType?.() ?? null,
|
||||
p2pLeafClasses: p2pLeaves.map((leaf) => leaf.containerEl?.className ?? null),
|
||||
rightSplitClasses: rightSplit?.className ?? null,
|
||||
};
|
||||
});
|
||||
if (!placement.inRightSplit) {
|
||||
throw new Error(`The mobile P2P status view was not opened in the right leaf: ${JSON.stringify(placement)}`);
|
||||
if (!opened) {
|
||||
throw new Error("The P2P status command was not registered or could not be executed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<string> {
|
||||
await openP2PStatusPane();
|
||||
return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
const onPageError = (error: Error) => runtimeErrors.push(`pageerror: ${error.message}`);
|
||||
const onConsole = (message: ConsoleMessage) => {
|
||||
if (message.type() === "error") runtimeErrors.push(`console: ${message.text()}`);
|
||||
};
|
||||
page.on("pageerror", onPageError);
|
||||
page.on("console", onConsole);
|
||||
let dispatchState: Awaited<ReturnType<typeof openP2PStatusPane>> | undefined;
|
||||
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
|
||||
try {
|
||||
dispatchState = await openP2PStatusPane(page);
|
||||
if (!dispatchState.opened) {
|
||||
throw new Error("The P2P status command was not registered or could not be executed.");
|
||||
}
|
||||
if (
|
||||
mobile &&
|
||||
(dispatchState.appIsMobile !== true ||
|
||||
dispatchState.apiIsMobile !== true ||
|
||||
dispatchState.bodyIsMobile !== true)
|
||||
) {
|
||||
throw new Error(
|
||||
`The mobile P2P command did not observe a fully mobile application state: ${JSON.stringify(dispatchState)}`
|
||||
);
|
||||
}
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
} catch (error) {
|
||||
const workspaceState = await collectP2PWorkspaceState(page);
|
||||
console.error(
|
||||
`P2P command state after failed open: ${JSON.stringify({ dispatchState, runtimeErrors })}`
|
||||
);
|
||||
console.error(`P2P workspace state after failed open: ${JSON.stringify(workspaceState)}`);
|
||||
throw error;
|
||||
} finally {
|
||||
page.off("pageerror", onPageError);
|
||||
page.off("console", onConsole);
|
||||
}
|
||||
if (mobile) {
|
||||
await assertMobileP2PPlacement(page);
|
||||
}
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
@@ -186,14 +51,7 @@ async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<s
|
||||
});
|
||||
const remoteSelector = pane.getByRole("combobox", { name: "Select active P2P remote" });
|
||||
await remoteSelector.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const remoteSelectionDeadline = Date.now() + uiTimeoutMs;
|
||||
let remoteConfigurationId = "";
|
||||
while (Date.now() < remoteSelectionDeadline) {
|
||||
remoteConfigurationId = (await remoteSelector.inputValue()).trim();
|
||||
if (remoteConfigurationId !== "") break;
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
if (remoteConfigurationId === "") {
|
||||
if ((await remoteSelector.inputValue()).trim() === "") {
|
||||
throw new Error("The configured P2P status pane did not select an active P2P remote.");
|
||||
}
|
||||
if (
|
||||
@@ -226,7 +84,7 @@ async function assertP2PUIIsOptIn(): Promise<void> {
|
||||
throw new Error("The retired P2P pane command is still exposed.");
|
||||
}
|
||||
if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) {
|
||||
throw new Error("The P2P status pane opened automatically for a CouchDB user without P2P configured.");
|
||||
throw new Error("The P2P status pane opened automatically for an unconfigured CouchDB user.");
|
||||
}
|
||||
if ((await page.locator(".livesync-ribbon-p2p-server-status").count()) !== 0) {
|
||||
throw new Error("The P2P ribbon icon was shown without a P2P configuration.");
|
||||
@@ -246,43 +104,12 @@ async function assertConfiguredP2PUIIsAvailable(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function assertConfiguredP2PCommandIsAvailable(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const state = await page.evaluate(() => {
|
||||
const app = (globalThis as ObsidianTestGlobal).app;
|
||||
const commands = app?.commands?.commands ?? {};
|
||||
return {
|
||||
commandRegistered: commands["obsidian-livesync:open-p2p-server-status"] !== undefined,
|
||||
openPaneCount: app?.workspace?.getLeavesOfType?.("p2p-server-status").length ?? 0,
|
||||
};
|
||||
});
|
||||
if (!state.commandRegistered) {
|
||||
throw new Error("The configured P2P status command was not registered in mobile mode.");
|
||||
}
|
||||
if (state.openPaneCount !== 0) {
|
||||
throw new Error("The configured P2P status pane opened before the mobile user requested it.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function dismissOpenNotices(page: Page): Promise<void> {
|
||||
const deadline = Date.now() + uiTimeoutMs;
|
||||
let quietSince = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
const dismissed = await page.evaluate(() => {
|
||||
const notices = (Array.from(document.querySelectorAll(".notice")) as HTMLElement[]).filter(
|
||||
(notice) => notice.checkVisibility?.() ?? notice.offsetParent !== null
|
||||
);
|
||||
for (const notice of notices) {
|
||||
const closeButton = notice.querySelector(".notice-close-button") as HTMLElement | null;
|
||||
// Obsidian 1.12 does not render a separate close control for
|
||||
// every Notice; clicking the Notice itself is its standard
|
||||
// dismiss action.
|
||||
(closeButton ?? notice).click();
|
||||
}
|
||||
return notices.length;
|
||||
});
|
||||
if (dismissed === 0) {
|
||||
const notices = page.locator(".notice:visible");
|
||||
if ((await notices.count()) === 0) {
|
||||
if (Date.now() - quietSince >= 500) {
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +117,14 @@ async function dismissOpenNotices(page: Page): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
quietSince = Date.now();
|
||||
await page.waitForTimeout(50);
|
||||
const closeButton = notices.first().locator(".notice-close-button");
|
||||
if ((await closeButton.count()) > 0) {
|
||||
await closeButton.click({ force: true, timeout: uiTimeoutMs });
|
||||
} else {
|
||||
// Obsidian 1.12 does not render a separate close control for every
|
||||
// Notice; clicking the Notice itself is its standard dismiss action.
|
||||
await notices.first().click({ force: true, position: { x: 2, y: 2 }, timeout: uiTimeoutMs });
|
||||
}
|
||||
}
|
||||
throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot.");
|
||||
}
|
||||
@@ -335,8 +169,7 @@ async function withP2PSession(
|
||||
binary: string,
|
||||
cliBinary: string,
|
||||
pluginData: Record<string, unknown>,
|
||||
verify: () => Promise<void>,
|
||||
options: { mobileBeforePluginStart?: boolean } = {}
|
||||
verify: () => Promise<void>
|
||||
): Promise<void> {
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
@@ -348,17 +181,6 @@ async function withP2PSession(
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
lifecycle: options.mobileBeforePluginStart
|
||||
? {
|
||||
beforePluginStart: async ({ remoteDebuggingPort }) => {
|
||||
await setObsidianMobileTestModeBeforePluginStart(
|
||||
remoteDebuggingPort,
|
||||
true,
|
||||
uiTimeoutMs
|
||||
);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await verify();
|
||||
@@ -388,25 +210,17 @@ async function main(): Promise<void> {
|
||||
async () => {
|
||||
await assertConfiguredP2PUIIsAvailable();
|
||||
const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false);
|
||||
console.log(
|
||||
`Configured P2P status UI remained opt-in and was reachable on desktop. Screenshot: ${desktopScreenshot}`
|
||||
);
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
|
||||
try {
|
||||
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
|
||||
console.log(
|
||||
`Configured P2P status UI remained opt-in and was reachable on desktop and mobile. Screenshots: ${desktopScreenshot}, ${mobileScreenshot}`
|
||||
);
|
||||
} finally {
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await withP2PSession(
|
||||
binary,
|
||||
cli.binary,
|
||||
createConfiguredP2PPluginData(),
|
||||
async () => {
|
||||
await assertConfiguredP2PCommandIsAvailable();
|
||||
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
|
||||
console.log(
|
||||
`Configured P2P status UI remained opt-in and was reachable on mobile. Screenshot: ${mobileScreenshot}`
|
||||
);
|
||||
},
|
||||
{ mobileBeforePluginStart: true }
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertLocatorHasMinimumTouchTarget,
|
||||
assertLocatorWithinSafeArea,
|
||||
@@ -8,17 +6,11 @@ import {
|
||||
import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { REVIEW_HARNESS_STATE_KEY } from "../../../src/features/ReviewHarness/reviewHarnessController.ts";
|
||||
import { REVIEW_HARNESS_FIXTURE_ROOT } from "../../../src/features/ReviewHarness/reviewHarnessVaultFixture.ts";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import {
|
||||
captureObsidianDialogue,
|
||||
captureObsidianPage,
|
||||
obsidianRemoteDebuggingPort,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS ?? 15000);
|
||||
@@ -34,135 +26,6 @@ type ReviewHarnessTestGlobal = typeof globalThis & {
|
||||
reviewHarnessCopiedReport?: string;
|
||||
};
|
||||
|
||||
type ReviewHarnessReadinessSnapshot = {
|
||||
coreAvailable: boolean;
|
||||
databaseReady?: boolean;
|
||||
appReady?: boolean;
|
||||
configured?: boolean;
|
||||
remoteType?: string;
|
||||
settingVersion?: number;
|
||||
suspended?: boolean;
|
||||
unresolvedMessages: string[];
|
||||
};
|
||||
|
||||
const sensitiveDiagnosticLine =
|
||||
/security seed|passphrase|password|credential|secret|access.?key|jwt.?key|authori[sz]ation|obsidian:\/\/setuplivesync|sls\+/iu;
|
||||
const interruptedStartupMessages = [
|
||||
"No replicator has been activated or has not been initialised yet.",
|
||||
"Self-hosted LiveSync cannot be initialised, exiting loading.",
|
||||
];
|
||||
|
||||
function redactDiagnosticLine(line: string): string {
|
||||
if (sensitiveDiagnosticLine.test(line)) return "[REDACTED SENSITIVE LOG LINE]";
|
||||
return line.replace(/\bhttps?:\/\/[^/\s:@]+:[^@\s/]+@/giu, "https://[REDACTED]@");
|
||||
}
|
||||
|
||||
async function assertNoInterruptedStartupNotice(stage: string): Promise<void> {
|
||||
const notices = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.waitForTimeout(1500);
|
||||
return await page.locator(".notice").allTextContents();
|
||||
});
|
||||
const interrupted = notices.filter((notice) =>
|
||||
interruptedStartupMessages.some((message) => notice.includes(message))
|
||||
);
|
||||
if (interrupted.length > 0) {
|
||||
throw new Error(`LiveSync emitted an interrupted-startup Notice during ${stage}: ${interrupted.join(" | ")}`);
|
||||
}
|
||||
console.log(`No interrupted-startup Notice observed during ${stage}.`);
|
||||
}
|
||||
|
||||
async function captureReadinessFailure(
|
||||
cliBinary: string,
|
||||
session: ObsidianLiveSyncSession,
|
||||
readinessError: unknown
|
||||
): Promise<void> {
|
||||
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const captureErrors: string[] = [];
|
||||
let screenshotPath: string | undefined;
|
||||
try {
|
||||
screenshotPath = await captureObsidianPage(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"review-harness-core-not-ready.png",
|
||||
async () => undefined
|
||||
);
|
||||
} catch (error) {
|
||||
captureErrors.push(`screenshot: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
let readiness: ReviewHarnessReadinessSnapshot | undefined;
|
||||
try {
|
||||
readiness = await evalObsidianJson<ReviewHarnessReadinessSnapshot>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync']?.core;",
|
||||
"if(!core)return JSON.stringify({coreAvailable:false,unresolvedMessages:[]});",
|
||||
"const settings=core.services.setting.currentSettings();",
|
||||
"let unresolvedMessages=[];",
|
||||
"try{",
|
||||
"unresolvedMessages=(await core.services.appLifecycle.getUnresolvedMessages()).flat()",
|
||||
".filter((message)=>message!==undefined&&message!==null)",
|
||||
".map((message)=>String(message)).slice(-50);",
|
||||
"}catch(error){unresolvedMessages=[`Could not inspect unresolved messages: ${String(error)}`];}",
|
||||
"return JSON.stringify({",
|
||||
"coreAvailable:true,",
|
||||
"databaseReady:core.services.database.isDatabaseReady(),",
|
||||
"appReady:core.services.appLifecycle.isReady(),",
|
||||
"configured:settings?.isConfigured===true,",
|
||||
"remoteType:settings?.remoteType??'',",
|
||||
"settingVersion:settings?.settingVersion,",
|
||||
"suspended:core.services.appLifecycle.isSuspended(),",
|
||||
"unresolvedMessages,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
readiness.unresolvedMessages = readiness.unresolvedMessages.map(redactDiagnosticLine);
|
||||
} catch (error) {
|
||||
captureErrors.push(`readiness snapshot: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
let recentLog: string[] = [];
|
||||
try {
|
||||
recentLog = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const opened = await page.evaluate(
|
||||
(commandId) =>
|
||||
(globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
"obsidian-livesync:view-log"
|
||||
);
|
||||
if (!opened) throw new Error("The Show log command was not registered.");
|
||||
const logPane = page.locator(".logpane");
|
||||
await logPane.waitFor({ state: "visible", timeout: 5000 });
|
||||
return (await logPane.locator(".log pre").allTextContents()).slice(-80).map(redactDiagnosticLine);
|
||||
});
|
||||
} catch (error) {
|
||||
captureErrors.push(`recent log: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const resultPath = join(outputDirectory, "review-harness-core-not-ready.json");
|
||||
await writeFile(
|
||||
resultPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
capturedAt: new Date().toISOString(),
|
||||
failure: readinessError instanceof Error ? readinessError.message : String(readinessError),
|
||||
screenshotPath,
|
||||
readiness,
|
||||
recentLog,
|
||||
captureErrors,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
if (screenshotPath) console.error(`Review Harness core readiness screenshot: ${screenshotPath}`);
|
||||
console.error(`Review Harness core readiness diagnostics: ${resultPath}`);
|
||||
}
|
||||
|
||||
async function openHarness(): Promise<void> {
|
||||
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
return await page.evaluate(
|
||||
@@ -340,8 +203,6 @@ async function copyAndReadReport(): Promise<string> {
|
||||
async function verifyMobileHarness(): Promise<string> {
|
||||
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const harness = page.locator('[data-testid="review-harness"]');
|
||||
if (await harness.isVisible()) return;
|
||||
await page.evaluate(async (viewType) => {
|
||||
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
|
||||
@@ -391,7 +252,7 @@ async function main(): Promise<void> {
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: {
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
doctorProcessedVersion: "0.25.27",
|
||||
settingVersion: CURRENT_SETTING_VERSION,
|
||||
isConfigured: true,
|
||||
additionalSuffixOfDatabaseName: "",
|
||||
@@ -408,20 +269,7 @@ async function main(): Promise<void> {
|
||||
periodicReplication: true,
|
||||
},
|
||||
});
|
||||
await assertNoInterruptedStartupNotice("plug-in session start");
|
||||
try {
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
} catch (error) {
|
||||
await captureReadinessFailure(cli.binary, session, error).catch((diagnosticError: unknown) => {
|
||||
console.error(
|
||||
`Could not capture Review Harness readiness diagnostics: ${
|
||||
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError)
|
||||
}`
|
||||
);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
await assertNoInterruptedStartupNotice("core readiness");
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await keepCompatibilityPaused();
|
||||
await openHarness();
|
||||
await waitForHarness();
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
const path = "revision-repair.md";
|
||||
const healthyDeletedPath = "healthy-logical-deletion.md";
|
||||
const baseContent = "Revision repair\n\nShared base.\n";
|
||||
const branchContents = [
|
||||
`Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`,
|
||||
@@ -30,11 +28,6 @@ type RevisionTree = {
|
||||
conflictRevisions: string[];
|
||||
};
|
||||
|
||||
type VaultWinnerState = {
|
||||
matches: boolean;
|
||||
winnerRevision: string;
|
||||
};
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
@@ -63,49 +56,6 @@ async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv):
|
||||
);
|
||||
}
|
||||
|
||||
async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise<string> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(healthyDeletedPath)};`,
|
||||
`const content=${JSON.stringify(`Healthy logical deletion\n\n${"D".repeat(4096)}\n`)};`,
|
||||
"let file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) file=await app.vault.create(path,content);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
await waitForLocalDatabaseEntry(cliBinary, env, healthyDeletedPath);
|
||||
return await evalObsidianJson<string>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(healthyDeletedPath)};`,
|
||||
`const timeoutMs=${JSON.stringify(uiTimeoutMs)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) throw new Error(`Logical-deletion fixture is missing from the Vault: ${path}`);",
|
||||
"await app.vault.delete(file);",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"const deadline=Date.now()+timeoutMs;",
|
||||
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
|
||||
"while(Date.now()<deadline){",
|
||||
" await core.services.fileProcessing.commitPendingFileEvents();",
|
||||
" const doc=await core.localDatabase.localDatabase.get(id,{conflicts:true}).catch(()=>false);",
|
||||
" if(!app.vault.getAbstractFileByPath(path)&&doc?.deleted&&(doc._conflicts??[]).length===0){",
|
||||
" return JSON.stringify(doc._rev);",
|
||||
" }",
|
||||
" await sleep(250);",
|
||||
"}",
|
||||
"throw new Error(`Timed out waiting for a healthy logical deletion: ${path}`);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function createBrokenConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
@@ -178,96 +128,6 @@ async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Prom
|
||||
);
|
||||
}
|
||||
|
||||
async function readVaultWinnerState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<VaultWinnerState> {
|
||||
return await evalObsidianJson<VaultWinnerState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) throw new Error(`Vault file is missing: ${path}`);",
|
||||
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
|
||||
"if(!entry||!entry._rev) throw new Error(`Database winner is missing: ${path}`);",
|
||||
"const vaultContent=await app.vault.read(file);",
|
||||
"const data=Array.isArray(entry.data)?entry.data:[entry.data];",
|
||||
"const databaseContent=await new Blob(data).text();",
|
||||
"return JSON.stringify({",
|
||||
" matches:vaultContent===databaseContent,",
|
||||
" winnerRevision:entry._rev,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function readFileReflectionProvenance(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath = path
|
||||
): Promise<{ revision: string; observedStorageMtime?: number } | null> {
|
||||
return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"return JSON.stringify((await store.get(path))??null);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
function repairCard(settings: Locator): Locator {
|
||||
return settings.locator(".sls-repair-result").filter({ hasText: path });
|
||||
}
|
||||
|
||||
function revisionCard(settings: Locator, revision: string): Locator {
|
||||
return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision });
|
||||
}
|
||||
|
||||
async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise<Locator> {
|
||||
const actionButton = revisionCard(settings, revision).getByRole("button", {
|
||||
name: `More actions for revision ${revision}`,
|
||||
exact: true,
|
||||
});
|
||||
await actionButton.locator("svg.lucide-wrench").waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await actionButton.click({ timeout: uiTimeoutMs });
|
||||
const menu = page.locator(".menu:visible").last();
|
||||
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const box = await menu.boundingBox();
|
||||
const viewport = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
if (
|
||||
box === null ||
|
||||
box.y < 0 ||
|
||||
box.y + box.height > viewport.height - 4
|
||||
) {
|
||||
throw new Error(
|
||||
`Revision action menu is outside the viewport: ${JSON.stringify({
|
||||
box,
|
||||
viewport,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise<void> {
|
||||
const menu = await openRevisionActionMenu(page, settings, revision);
|
||||
const item = menu.getByText(action, { exact: true });
|
||||
await item.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await item.click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
@@ -327,22 +187,7 @@ async function main(): Promise<void> {
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await createAndOpenBaseFile(cliBinary, session.cliEnv);
|
||||
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||
const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv);
|
||||
const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev);
|
||||
const healthyDeletionProvenance = await readFileReflectionProvenance(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
healthyDeletedPath
|
||||
);
|
||||
if (healthyDeletionProvenance !== null) {
|
||||
throw new Error(
|
||||
`A healthy logical deletion retained Vault provenance indefinitely: ${JSON.stringify({
|
||||
healthyDeletedPath,
|
||||
healthyDeletionRevision,
|
||||
healthyDeletionProvenance,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
await requestConflictCheck(cliBinary, session.cliEnv);
|
||||
const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
@@ -369,24 +214,18 @@ async function main(): Promise<void> {
|
||||
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
|
||||
const verifySetting = settings.locator(".setting-item").filter({
|
||||
has: page.getByText("Inspect conflicts and file/database differences", {
|
||||
exact: true,
|
||||
}),
|
||||
has: page.getByText("Verify and repair all files", { exact: true }),
|
||||
});
|
||||
await verifySetting.getByRole("button", { name: "Begin inspection", exact: true }).click({
|
||||
await verifySetting.getByRole("button", { name: "Verify all", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const card = repairCard(settings);
|
||||
const card = settings.locator(".sls-repair-result").filter({ hasText: path });
|
||||
await card.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if ((await settings.locator(".sls-repair-result").filter({ hasText: healthyDeletedPath }).count()) !== 0) {
|
||||
throw new Error(
|
||||
`File/database inspection reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).`
|
||||
);
|
||||
}
|
||||
const winnerRevision = revisionCard(settings, fixture.winnerRevision);
|
||||
const brokenRevision = revisionCard(settings, fixture.conflictRevision);
|
||||
const brokenRevision = card
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision });
|
||||
await brokenRevision
|
||||
.getByText(/🧩 Missing chunks: 1/u)
|
||||
.getByText(/Unreadable on this device/u)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({
|
||||
state: "visible",
|
||||
@@ -395,276 +234,43 @@ async function main(): Promise<void> {
|
||||
if ((await card.locator(".sls-repair-revision").count()) !== 2) {
|
||||
throw new Error("Verify and Repair did not render the winner and conflict revision separately.");
|
||||
}
|
||||
for (const label of [
|
||||
/📦 DB: recorded/u,
|
||||
/📁 Vault:/u,
|
||||
/Δsize vs DB/u,
|
||||
/🕒 DB /u,
|
||||
/Δtime /u,
|
||||
/⚠️ Differs from Vault/u,
|
||||
]) {
|
||||
await winnerRevision.getByText(label).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
await brokenRevision.getByText(/decoded unavailable/u).waitFor({
|
||||
state: "visible",
|
||||
|
||||
await brokenRevision.getByRole("button", { name: "Retry reading revision", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const winnerMenu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
|
||||
for (const label of [
|
||||
"Compare with Vault",
|
||||
"Apply this revision to Vault",
|
||||
"Store Vault file as a child of this revision",
|
||||
"Discard this branch",
|
||||
]) {
|
||||
await winnerMenu.getByText(label, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
if (
|
||||
(await winnerMenu
|
||||
.getByText("Mark this revision as the Vault version", {
|
||||
exact: true,
|
||||
})
|
||||
.count()) !== 0
|
||||
) {
|
||||
throw new Error("A differing revision incorrectly offered to record an exact Vault match.");
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
const repairCardScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-unreadable-conflict.png",
|
||||
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const card = page.locator(".sls-repair-result").filter({ hasText: path });
|
||||
await card.evaluate((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
htmlElement.dataset.e2eOriginalStyle = htmlElement.getAttribute("style") ?? "";
|
||||
htmlElement.style.width = "360px";
|
||||
htmlElement.style.maxWidth = "100%";
|
||||
});
|
||||
const dimensions = await card.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
}));
|
||||
if (dimensions.scrollWidth > dimensions.clientWidth + 1) {
|
||||
throw new Error(
|
||||
`Revision repair card overflowed at mobile width: ${JSON.stringify(dimensions)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
const mobileWidthScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-mobile-width.png",
|
||||
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const card = page.locator(".sls-repair-result").filter({ hasText: path });
|
||||
await card.evaluate((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
const originalStyle = htmlElement.dataset.e2eOriginalStyle ?? "";
|
||||
if (originalStyle.length > 0) {
|
||||
htmlElement.setAttribute("style", originalStyle);
|
||||
} else {
|
||||
htmlElement.removeAttribute("style");
|
||||
}
|
||||
delete htmlElement.dataset.e2eOriginalStyle;
|
||||
});
|
||||
});
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
await openRevisionActionMenu(page, settings, fixture.winnerRevision);
|
||||
});
|
||||
const readableMenuScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-readable-actions.png",
|
||||
(page) => page.locator(".menu:visible").last()
|
||||
);
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.keyboard.press("Escape");
|
||||
const settings = page.locator(".sls-setting");
|
||||
await selectRevisionAction(page, settings, fixture.winnerRevision, "Compare with Vault");
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Vault and database revision",
|
||||
}),
|
||||
});
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.getByText(path, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await modal.getByText(/Vault file:/u).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await modal.getByText(/Database revision:/u).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const actions = modal.locator(".conflict-action-container");
|
||||
await actions.getByRole("button", { name: "Close", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
for (const action of ["Use Vault file", "Use Database revision", "Concat both", "Not now"]) {
|
||||
if ((await actions.getByRole("button", { name: action, exact: true }).count()) !== 0) {
|
||||
throw new Error(`Read-only comparison exposed the resolution action '${action}'.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
const comparisonScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-read-only-comparison.png",
|
||||
(page) =>
|
||||
page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Vault and database revision",
|
||||
}),
|
||||
})
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Vault and database revision",
|
||||
}),
|
||||
});
|
||||
await modal
|
||||
.locator(".conflict-action-container")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const beforeApply = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
await selectRevisionAction(page, settings, fixture.winnerRevision, "Apply this revision to Vault");
|
||||
const confirmation = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Apply database revision to Vault",
|
||||
}),
|
||||
});
|
||||
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await revisionCard(settings, fixture.winnerRevision)
|
||||
.getByText("✅ Matches Vault", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const status = repairCard(settings).locator(".sls-repair-status");
|
||||
await status
|
||||
.getByText("✅ Vault matches winner", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await status
|
||||
.getByText("⚠️ Conflicts: 1", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
const matchedWinnerWithConflictScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-winner-match-with-conflict.png",
|
||||
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
|
||||
);
|
||||
const afterApply = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (JSON.stringify(afterApply) !== JSON.stringify(beforeApply)) {
|
||||
throw new Error(
|
||||
`Applying a live revision to the Vault changed the revision tree: ${JSON.stringify({
|
||||
beforeApply,
|
||||
afterApply,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
const vaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
|
||||
const appliedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
|
||||
if (
|
||||
!vaultWinner.matches ||
|
||||
vaultWinner.winnerRevision !== fixture.winnerRevision ||
|
||||
appliedProvenance?.revision !== fixture.winnerRevision
|
||||
) {
|
||||
throw new Error(
|
||||
`Applying the winner did not preserve exact Vault provenance: ${JSON.stringify({
|
||||
vaultWinner,
|
||||
appliedProvenance,
|
||||
fixture,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
const menu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
|
||||
await menu
|
||||
.getByText("Mark this revision as the Vault version", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await menu
|
||||
.getByText("Discard this branch", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await menu
|
||||
.getByText("Mark this revision as the Vault version", { exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await revisionCard(settings, fixture.winnerRevision)
|
||||
.getByText("✅ Matches Vault", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
const afterExactMark = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
const markedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
|
||||
if (
|
||||
JSON.stringify(afterExactMark) !== JSON.stringify(beforeApply) ||
|
||||
markedProvenance?.revision !== fixture.winnerRevision
|
||||
) {
|
||||
throw new Error(
|
||||
`Recording an exact Vault match changed the tree or lost provenance: ${JSON.stringify({
|
||||
beforeApply,
|
||||
afterExactMark,
|
||||
markedProvenance,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
const menu = await openRevisionActionMenu(page, settings, fixture.conflictRevision);
|
||||
for (const label of [
|
||||
"Store Vault file as a child of this revision",
|
||||
"Retry reading revision",
|
||||
"Discard this branch",
|
||||
]) {
|
||||
await menu.getByText(label, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
});
|
||||
const unreadableMenuScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-unreadable-actions-context.png",
|
||||
(page) => page.locator("body")
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.keyboard.press("Escape");
|
||||
const settings = page.locator(".sls-setting");
|
||||
await selectRevisionAction(page, settings, fixture.conflictRevision, "Retry reading revision");
|
||||
await revisionCard(settings, fixture.conflictRevision)
|
||||
.getByText(/🧩 Missing chunks:/u)
|
||||
await settings
|
||||
.locator(".sls-repair-result")
|
||||
.filter({ hasText: path })
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision })
|
||||
.getByText(/Unreadable on this device/u)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const afterRetry = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (JSON.stringify(afterRetry) !== JSON.stringify(beforeApply)) {
|
||||
if (!afterRetry.conflictRevisions.includes(fixture.conflictRevision)) {
|
||||
throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`);
|
||||
}
|
||||
|
||||
const screenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-unreadable-conflict.png",
|
||||
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
|
||||
);
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
|
||||
const brokenRevision = () =>
|
||||
settings
|
||||
.locator(".sls-repair-result")
|
||||
.filter({ hasText: path })
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision });
|
||||
await brokenRevision()
|
||||
.getByRole("button", { name: "Discard unreadable revision", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
const confirmation = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
|
||||
});
|
||||
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
@@ -672,23 +278,36 @@ async function main(): Promise<void> {
|
||||
});
|
||||
|
||||
const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (JSON.stringify(afterCancellation) !== JSON.stringify(beforeApply)) {
|
||||
if (!afterCancellation.conflictRevisions.includes(fixture.conflictRevision)) {
|
||||
throw new Error(`Cancelling discard changed the revision tree: ${JSON.stringify(afterCancellation)}`);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
|
||||
const brokenRevision = settings
|
||||
.locator(".sls-repair-result")
|
||||
.filter({ hasText: path })
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision });
|
||||
await brokenRevision
|
||||
.getByRole("button", { name: "Discard unreadable revision", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
const confirmation = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
|
||||
});
|
||||
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await repairCard(settings).waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await settings
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision })
|
||||
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (afterDiscard.winnerRevision !== fixture.winnerRevision || afterDiscard.conflictRevisions.length !== 0) {
|
||||
if (
|
||||
afterDiscard.winnerRevision !== fixture.winnerRevision ||
|
||||
afterDiscard.conflictRevisions.length !== 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({
|
||||
fixture,
|
||||
@@ -696,31 +315,11 @@ async function main(): Promise<void> {
|
||||
})}`
|
||||
);
|
||||
}
|
||||
const finalVaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
|
||||
const finalProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
|
||||
if (
|
||||
!finalVaultWinner.matches ||
|
||||
finalVaultWinner.winnerRevision !== fixture.winnerRevision ||
|
||||
finalProvenance?.revision !== fixture.winnerRevision
|
||||
) {
|
||||
throw new Error(
|
||||
`Discarding the unreadable branch disturbed the healthy Vault reflection: ${JSON.stringify({
|
||||
finalVaultWinner,
|
||||
finalProvenance,
|
||||
fixture,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation."
|
||||
"Real Obsidian kept an unreadable conflict revision through automatic checking and retry, rendered every live revision separately, required confirmation, and discarded only the selected revision."
|
||||
);
|
||||
console.log(`Repair card screenshot: ${repairCardScreenshot}`);
|
||||
console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`);
|
||||
console.log(`Readable revision actions screenshot: ${readableMenuScreenshot}`);
|
||||
console.log(`Read-only comparison screenshot: ${comparisonScreenshot}`);
|
||||
console.log(`Matching winner with conflict screenshot: ${matchedWinnerWithConflictScreenshot}`);
|
||||
console.log(`Unreadable revision actions screenshot: ${unreadableMenuScreenshot}`);
|
||||
console.log(`Repair screenshot: ${screenshot}`);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
|
||||
@@ -1,26 +1,3 @@
|
||||
/**
|
||||
* Provides release evidence for the Security Seed refresh behaviour shared by
|
||||
* supported platforms in real Obsidian. It verifies that an already-open
|
||||
* device keeps its deliberately stale cached Seed until replication, refreshes
|
||||
* from the managed CouchDB fixture before encrypting, and never restores the
|
||||
* old Seed to the remote synchronisation-parameter document.
|
||||
*
|
||||
* The scenario uses isolated Vaults, profiles, and a random database because
|
||||
* settings, the local database, the renderer process, and CouchDB must all
|
||||
* participate in the result. Device A is restarted with the same Vault and
|
||||
* profile, while device B is fresh. The devices run sequentially after the
|
||||
* same-process stale-cache assertion because desktop Obsidian may enforce a
|
||||
* single application instance; running them concurrently would test launcher
|
||||
* behaviour rather than LiveSync's shared plug-in implementation.
|
||||
*
|
||||
* Seed replacement, A-to-B decryption, B-to-A return synchronisation, final
|
||||
* remote-document comparison, error-log inspection, screenshots, and strict
|
||||
* teardown remain one scenario. Together they prove that the same replacement
|
||||
* Seed was used across the complete encrypted round trip and was not later
|
||||
* rolled back. Independent passing checks would not establish that continuity.
|
||||
* The result records fingerprints only and does not claim to cover an
|
||||
* iPadOS-specific background or reconnect lifecycle.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
/**
|
||||
* Proves that a configured LiveSync Vault scans files created while Obsidian
|
||||
* was stopped. The first launch receives a CouchDB profile using current
|
||||
* settings and its acknowledged device-local compatibility marker before the
|
||||
* plug-in loads.
|
||||
*
|
||||
* The second launch reuses the same Vault, profile, local database, and
|
||||
* settings without rewriting plug-in data, so the assertion covers an
|
||||
* ordinary configured restart rather than the separate onboarding flow.
|
||||
*/
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
@@ -21,8 +11,7 @@ import {
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
configureCouchDb,
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
@@ -58,12 +47,6 @@ async function main(): Promise<void> {
|
||||
|
||||
const couchDb = await loadCouchDbConfig();
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "startup-scan");
|
||||
const couchDbSettings = {
|
||||
uri: couchDb.uri,
|
||||
username: couchDb.username,
|
||||
password: couchDb.password,
|
||||
dbName,
|
||||
};
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
@@ -80,11 +63,15 @@ async function main(): Promise<void> {
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: createE2eCouchDbPluginData(couchDbSettings),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
const initialReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
assertEqual(initialReadiness.configured, true, "Self-hosted LiveSync did not start configured.");
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
|
||||
uri: couchDb.uri,
|
||||
username: couchDb.username,
|
||||
password: couchDb.password,
|
||||
dbName,
|
||||
});
|
||||
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not configured.");
|
||||
await prepareRemote(cli.binary, session.cliEnv);
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
@@ -97,8 +84,7 @@ async function main(): Promise<void> {
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
});
|
||||
const restartedReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
assertEqual(restartedReadiness.configured, true, "Self-hosted LiveSync lost its configuration on restart.");
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
|
||||
const localEntry = await waitForLocalDatabaseEntry(cli.binary, session.cliEnv, notePath);
|
||||
await pushLocalChanges(cli.binary, session.cliEnv);
|
||||
|
||||
+79
-61
@@ -12,99 +12,117 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 1.0.0
|
||||
## 1.0.0-beta.4
|
||||
|
||||
27th July, 2026
|
||||
25th July, 2026
|
||||
|
||||
The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here).
|
||||
### Improved
|
||||
|
||||
### Setup and compatibility
|
||||
- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation.
|
||||
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**.
|
||||
- Text in setup and review dialogues can now be selected for copying or translation.
|
||||
- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue.
|
||||
|
||||
#### Improved
|
||||
### Fixed
|
||||
|
||||
- An unconfigured Vault now waits for the user to start setup. Onboarding is offered through a persistent Notice and remains available from **Self-hosted LiveSync settings** → **Setup**.
|
||||
- Setup now creates named CouchDB, Object Storage, and P2P connections. Setup URIs preserve their connection names and selections, and reserve Fetch or Rebuild before the ordinary start-up scan begins.
|
||||
- Manual CouchDB setup distinguishes creating the first database from connecting another device. Onboarding requires a successful connection, while Settings can explicitly save an unverified connection and offers each server-setting correction separately.
|
||||
- Compatible differences limited to the chunk hash algorithm, chunk size, or splitter version are aligned automatically by default. Existing chunks remain readable, an explicit opt-out remains available, and differences involving incompatible settings still require review.
|
||||
- An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device.
|
||||
- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message.
|
||||
|
||||
#### Fixed
|
||||
### Testing
|
||||
|
||||
- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting.
|
||||
- Added regressions for revision repair, command availability, selectable dialogues, conflict-aware chunk reachability, device-progress safeguards, and compaction timeouts.
|
||||
- Added a real CouchDB integration test for logical chunk deletion, shared and conflict chunk retention, compaction completion, downstream replication, and content-addressed chunk recreation.
|
||||
- Added a real Obsidian encrypted reconnect scenario which replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation refreshes it without restoring the old value, and proves a bidirectional encrypted round-trip.
|
||||
|
||||
#### Security
|
||||
## 1.0.0-beta.3
|
||||
|
||||
- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness.
|
||||
- Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links.
|
||||
24th July, 2026
|
||||
|
||||
### Conflict handling and recovery
|
||||
### Improved
|
||||
|
||||
#### Improved
|
||||
- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices.
|
||||
- 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.
|
||||
|
||||
- **Not now** postpones repeated automatic merge dialogues while retaining the unresolved-conflict warning. Three or more live revisions are reviewed one reproducible pair at a time, completed pairs remain resolved across restart, and explicit commands can reopen a postponed conflict.
|
||||
- **Inspect conflicts and file/database differences** compares the Vault with the database winner and every live conflict revision. Compact indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflicts remain.
|
||||
- Each reported file and live revision has a compact wrench menu for comparison, applying an exact readable revision, recording an exact byte match, storing the Vault content as a child of a selected branch, retrying missing chunks without changing the tree, or explicitly discarding one selected live branch.
|
||||
### Fixed
|
||||
|
||||
#### Fixed
|
||||
- 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.
|
||||
|
||||
- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch.
|
||||
- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review.
|
||||
- Unreadable live revisions are preserved during automatic handling. An absent Vault file and a winning logical deletion are treated as agreement unless another live branch still requires attention.
|
||||
- Garbage Collection V3 is limited to CouchDB and now protects every live conflict branch, required shared ancestry, and shared chunks. It stops when device progress cannot be verified and reports compaction failure without a contradictory success message.
|
||||
### Testing
|
||||
|
||||
### P2P and optional synchronisation features
|
||||
- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues.
|
||||
|
||||
#### Improved
|
||||
## 1.0.0-beta.2
|
||||
|
||||
- P2P and Hidden File Sync remain supported opt-in features. Customisation Sync remains a supported Advanced workflow, while Data Compression remains available but disabled by default.
|
||||
- P2P controls remain outside the ordinary CouchDB experience until P2P is configured. The current status pane distinguishes announcing changes, following a peer, and persistent per-device actions.
|
||||
- P2P setup and guidance now distinguish the required signalling relay from optional TURN and describe the replaceable public relay's privacy and availability limits.
|
||||
- Enabling Hidden File Sync opens one progress Notice before saving the setting and reuses it until the initial scan has finished instead of stacking phase, reload, and restart messages.
|
||||
23rd July, 2026
|
||||
|
||||
#### Fixed
|
||||
### Improved
|
||||
|
||||
- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild.
|
||||
- P2P relay connections now close and are recreated reliably after settings changes and database resets.
|
||||
- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved.
|
||||
|
||||
### Interface, translation, and operations
|
||||
### Fixed
|
||||
|
||||
#### Improved
|
||||
- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred.
|
||||
|
||||
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands retain their identifiers so that existing hotkeys continue to work.
|
||||
- Setup and review dialogue text can be selected for copying or translation.
|
||||
- Remote-size warnings use persistent clickable Notices. Initial uploads and Rebuild no longer ask to send every chunk in advance; ordinary replication completes the transfer.
|
||||
- Obsolete controls for the plug-in trash setting and fixed chunk revisions were removed. The Change Log remains available but no longer opens automatically or tracks an unread count.
|
||||
- Self-hosted LiveSync now owns its translation catalogue. Commonlib supplies canonical English to other consumers, while translation contributions can be made in the main Self-hosted LiveSync repository.
|
||||
### Testing
|
||||
|
||||
#### Fixed
|
||||
- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages.
|
||||
|
||||
- Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand.
|
||||
- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls.
|
||||
## 1.0.0-beta.1
|
||||
|
||||
### Storage and file selection
|
||||
22nd July, 2026
|
||||
|
||||
#### Fixed
|
||||
### Important
|
||||
|
||||
- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
|
||||
- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update.
|
||||
- Start-up and full-inspection scans omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged.
|
||||
- This corrected opt-in integration preview follows `1.0.0-beta.0` and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault.
|
||||
|
||||
### Command-line tool
|
||||
### Fixed
|
||||
|
||||
#### Fixed
|
||||
- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers.
|
||||
- Edits, deletions, and renames made while a file is conflicted now extend the exact revision displayed on that device. If LiveSync cannot prove the displayed branch, it preserves the affected branches for review instead of silently applying the operation to the database winner.
|
||||
|
||||
- CLI Setup URI validation now uses the supported Commonlib ESM package interface.
|
||||
- The non-root Docker image no longer depends on permissions inherited from the source checkout.
|
||||
### Testing
|
||||
|
||||
#### Security
|
||||
- Added revision-tree regressions and focused real-Obsidian scenarios for propagated resolutions and file operations performed while a conflict remains active.
|
||||
|
||||
- The CLI rejects detected path traversal and symbolic-link components before Vault operations.
|
||||
## 1.0.0-beta.0
|
||||
|
||||
### Validation
|
||||
22nd July, 2026
|
||||
|
||||
#### Testing
|
||||
### Important
|
||||
|
||||
- Expanded automated Real Obsidian coverage for upgrades, two-device synchronisation, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, conflict and revision recovery, failure diagnostics, and strict clean-up.
|
||||
- Real CouchDB integration coverage verifies logical deletion, shared and conflict chunk retention, compaction, downstream replication, and recreation of content-addressed chunks.
|
||||
- An encrypted Real Obsidian reconnect scenario replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation adopts the replacement without restoring the old value, and proves a bidirectional encrypted round-trip.
|
||||
- The plug-in code in this release was installed through BRAT and validated on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations.
|
||||
- Native and non-root Docker CLI scenarios cover setup, write, read, list, information, deletion, conflict resolution, and revision retrieval with the packaged Commonlib dependency.
|
||||
- This is an opt-in 1.0 integration preview for BRAT and testing with existing Vaults. It does not replace the latest stable release. Use it with a current backup, and update every participating device before resuming synchronisation.
|
||||
- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. The review preserves the existing automatic synchronisation choices and resumes them only after the decision has been saved successfully.
|
||||
|
||||
### Improved
|
||||
|
||||
- An unconfigured installation now waits for you to start setup. A long-lived Notice offers the setup action, and **Open onboarding wizard** remains available from the command palette instead of the dialogue opening automatically.
|
||||
- The setup wizard now creates named remote profiles for CouchDB, Object Storage, and P2P. Current Setup URIs preserve their profile names and selections, and the wizard reserves Rebuild or Fetch before the ordinary start-up scan begins.
|
||||
- Peer-to-Peer Synchronisation (P2P) and Hidden File Sync are supported opt-in features. JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews. Customisation Sync remains a supported advanced workflow.
|
||||
- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the Data Compression specification.
|
||||
- Compatibility review now runs before Config Doctor without overlapping it. Existing Vaults retain their automatic synchronisation choices and explicit file-name case setting. For installations created by earlier releases, LiveSync preserves whether setup had been completed and saves a missing legacy case setting as case-insensitive.
|
||||
- P2P connections now restart reliably after settings are reapplied or the local database is reset. Setup on an additional device asks you to select the source device once. Disconnecting leaves the LiveSync room and closes its signalling relay connections so that reconnecting can establish a new room.
|
||||
- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in issue #555.
|
||||
- Warnings about estimated remote storage size now appear as long-lived clickable Notices instead of timed dialogues. Initial uploads and Rebuild operations no longer prompt to send every chunk in advance; ordinary replication completes the transfer.
|
||||
- Removed the obsolete **Use the trash bin** control and the setting for fixed chunk revisions. Remote deletion still follows Obsidian's preference, and chunk revisions remain content-derived. The Change Log remains available but no longer opens automatically or tracks unread versions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The optional Custom HTTP Handler used by Object Storage now sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
|
||||
- When selectors, ignore files, size limits, modification-time limits, or file-name case settings are broadened, LiveSync now rechecks previously received files without requiring another remote update.
|
||||
- P2P setup on the first device no longer displays reset or upload steps for a central database, and Config Doctor now offers its chunk size recommendation for CouchDB only when a CouchDB remote profile is selected.
|
||||
|
||||
### Security
|
||||
|
||||
- Fly.io setup now generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates prevent excessive CPU use from specially crafted path patterns and `mailto:` links. The CLI rejects path traversal and symbolic-link components detected before Vault operations.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- Self-hosted LiveSync now owns its translation catalogue. Commonlib provides English messages to other applications, and translation contributions can be made directly to the Self-hosted LiveSync repository.
|
||||
|
||||
### Testing
|
||||
|
||||
- Expanded automated testing in Obsidian for upgrades, synchronisation between two devices, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, and clean-up after failures.
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
The release history is now kept as one chronological sequence across smaller files:
|
||||
|
||||
- [Current 1.x releases](updates.md)
|
||||
- [1.0 beta and release-candidate history](docs/releases/1.0-previews.md)
|
||||
- [0.25 releases](docs/releases/0.25.md)
|
||||
- [Releases before 0.25](docs/releases/legacy.md)
|
||||
|
||||
|
||||
@@ -20,10 +20,9 @@ function inlineCode(value) {
|
||||
/**
|
||||
* Render the reader-facing checklist for a draft release pull request.
|
||||
*
|
||||
* The version decides whether the release commit is an immutable SemVer
|
||||
* pre-release or a stable version staged through a GitHub pre-release. The
|
||||
* base branch is included explicitly because integration previews can target
|
||||
* a reviewed integration branch rather than `main`.
|
||||
* The version decides whether publication is stable or pre-release. The base
|
||||
* branch is included explicitly because integration previews can target a
|
||||
* reviewed integration branch rather than `main`.
|
||||
*
|
||||
* @param {string} version
|
||||
* @param {string} baseBranch
|
||||
@@ -40,29 +39,13 @@ export function renderReleasePrBody(version, baseBranch) {
|
||||
const isPrerelease = selectedVersion.includes("-");
|
||||
const purpose = isPrerelease
|
||||
? `an immutable pre-release for BRAT validation without replacing the latest stable release`
|
||||
: `a stable version which will first be staged as a GitHub pre-release for BRAT validation`;
|
||||
: `the next stable release`;
|
||||
const finaliseInstruction = isPrerelease
|
||||
? "Run the finalise release workflow with this PR's fixed head SHA and `prerelease=true`"
|
||||
: "Run the finalise release workflow with this PR's fixed head SHA, `prerelease=true`, and `publish_cli=false`";
|
||||
: "Run the finalise release workflow with this PR's fixed head SHA and `prerelease=false`";
|
||||
const publicationInstruction = isPrerelease
|
||||
? "Publish the GitHub Release as a pre-release without replacing the latest stable release, while keeping this pull request in draft"
|
||||
: "Publish the GitHub Release initially as a pre-release without replacing the latest stable release, while keeping this pull request in draft";
|
||||
const assetInstruction = isPrerelease
|
||||
? "Confirm the draft GitHub Release assets and the published CLI image, if selected"
|
||||
: "Confirm the draft GitHub Release assets; keep stable CLI publication deferred until BRAT validation passes";
|
||||
const holdInstruction = isPrerelease
|
||||
? `Publishing and validating this pre-release does not unblock this pull request. Keep it in draft and unmerged, and leave ${baseBranchCode} unchanged.`
|
||||
: `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation. Promotion remains on hold until the exact release commit has been integrated into the repository's default branch.`;
|
||||
const completionInstructions = isPrerelease
|
||||
? [
|
||||
"- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action",
|
||||
]
|
||||
: [
|
||||
`- [ ] After BRAT validation passes, mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`,
|
||||
"- [ ] Integrate the exact release commit through the reviewed branch chain into the repository's default branch",
|
||||
"- [ ] Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release",
|
||||
"- [ ] Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate",
|
||||
];
|
||||
: "Publish the GitHub Release as the latest stable release while keeping this pull request in draft";
|
||||
|
||||
return [
|
||||
`This release pull request prepares Self-hosted LiveSync ${versionCode} from ${baseBranchCode} as ${purpose}.`,
|
||||
@@ -70,7 +53,7 @@ export function renderReleasePrBody(version, baseBranch) {
|
||||
"> [!IMPORTANT]",
|
||||
"> **Merge intentionally on hold**",
|
||||
">",
|
||||
`> ${holdInstruction}`,
|
||||
`> Publishing the GitHub Release does not unblock this pull request. Keep this pull request in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation.`,
|
||||
"",
|
||||
"## Release checklist",
|
||||
"",
|
||||
@@ -79,10 +62,10 @@ export function renderReleasePrBody(version, baseBranch) {
|
||||
"- [ ] Confirm `manifest.json`, `versions.json`, workspace package versions, and the locked Commonlib package version",
|
||||
"- [ ] Confirm CI has passed",
|
||||
`- [ ] ${finaliseInstruction}`,
|
||||
`- [ ] ${assetInstruction}`,
|
||||
"- [ ] Confirm the draft GitHub Release assets and the published CLI image, if selected",
|
||||
`- [ ] ${publicationInstruction}`,
|
||||
"- [ ] Validate the exact published release with BRAT",
|
||||
...completionInstructions,
|
||||
`- [ ] Mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { renderReleasePrBody } from "./release-pr-body.mjs";
|
||||
import { ensureTags } from "./release-tags.mjs";
|
||||
|
||||
const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url));
|
||||
const releasePrBodyScript = fileURLToPath(new URL("./release-pr-body.mjs", import.meta.url));
|
||||
const versionBumpScript =
|
||||
process.env.VERSION_BUMP_SCRIPT || fileURLToPath(new URL("../version-bump.mjs", import.meta.url));
|
||||
const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs", import.meta.url));
|
||||
@@ -160,12 +160,13 @@ describe("release notes", () => {
|
||||
describe("release workflow", () => {
|
||||
it("uses the locked Commonlib package instead of generated fallback declarations", () => {
|
||||
const workflow = readFileSync(prepareReleaseWorkflow, "utf8");
|
||||
const body = renderReleasePrBody("1.0.0-beta.0", "integration");
|
||||
const body = runNode(releasePrBodyScript, ["1.0.0-beta.0", "integration"], makeTemporaryDirectory());
|
||||
|
||||
expect(workflow).not.toContain("npm run build:lib:types");
|
||||
expect(workflow).not.toMatch(/git add[^\n]*_types/);
|
||||
expect(workflow).toMatch(/git add[^\n]*package-lock\.json/);
|
||||
expect(body).toContain("locked Commonlib package version");
|
||||
expect(body.status, body.stderr).toBe(0);
|
||||
expect(body.stdout).toContain("locked Commonlib package version");
|
||||
});
|
||||
|
||||
it("reruns the version lifecycle when the integration branch already selects the release version", () => {
|
||||
@@ -182,90 +183,48 @@ describe("release workflow", () => {
|
||||
expect(workflow).not.toContain("latest stable release");
|
||||
});
|
||||
|
||||
it("keeps an immutable pre-release out of its base branch after BRAT validation", () => {
|
||||
const prerelease = renderReleasePrBody("1.0.0-rc.0", "common-library-package-boundary");
|
||||
it("keeps the release PR in draft until BRAT validation", () => {
|
||||
const prerelease = runNode(
|
||||
releasePrBodyScript,
|
||||
["1.0.0-beta.0", "common-library-package-boundary"],
|
||||
makeTemporaryDirectory()
|
||||
);
|
||||
|
||||
expect(prerelease).toContain("Merge intentionally on hold");
|
||||
expect(prerelease).toContain("Self-hosted LiveSync `1.0.0-rc.0`");
|
||||
expect(prerelease).toContain("leave `common-library-package-boundary` unchanged");
|
||||
expect(prerelease).toContain("prerelease=true");
|
||||
expect(prerelease).toContain(
|
||||
expect(prerelease.status, prerelease.stderr).toBe(0);
|
||||
expect(prerelease.stdout).toContain("Merge intentionally on hold");
|
||||
expect(prerelease.stdout).toContain("Self-hosted LiveSync `1.0.0-beta.0`");
|
||||
expect(prerelease.stdout).toContain("leave `common-library-package-boundary` unchanged");
|
||||
expect(prerelease.stdout).toContain("prerelease=true");
|
||||
expect(prerelease.stdout).toContain(
|
||||
"Publish the GitHub Release as a pre-release without replacing the latest stable release"
|
||||
);
|
||||
expect(prerelease).toContain("Validate the exact published release with BRAT");
|
||||
expect(prerelease).toContain("Keep this pre-release pull request unmerged");
|
||||
expect(prerelease).toContain("close it only through a separate maintainer action");
|
||||
expect(prerelease).not.toContain("Mark this pull request ready and merge it");
|
||||
});
|
||||
|
||||
it("publishes a stable version initially as a GitHub pre-release for BRAT validation", () => {
|
||||
const stable = renderReleasePrBody("1.0.0", "main");
|
||||
|
||||
expect(stable).toContain("prerelease=true");
|
||||
expect(stable).toContain("publish_cli=false");
|
||||
expect(stable).toContain(
|
||||
"Publish the GitHub Release initially as a pre-release without replacing the latest stable release"
|
||||
);
|
||||
expect(stable).toContain(
|
||||
"After BRAT validation passes, mark this pull request ready and merge it into `main` with a merge commit"
|
||||
);
|
||||
expect(stable).toContain(
|
||||
"Integrate the exact release commit through the reviewed branch chain into the repository's default branch"
|
||||
);
|
||||
expect(stable).toContain(
|
||||
"Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release"
|
||||
);
|
||||
expect(stable).toContain(
|
||||
"Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate"
|
||||
);
|
||||
expect(stable.indexOf("After BRAT validation passes")).toBeLessThan(
|
||||
stable.indexOf("Integrate the exact release commit")
|
||||
);
|
||||
expect(stable.indexOf("Integrate the exact release commit")).toBeLessThan(
|
||||
stable.indexOf("Confirm the default branch contains the exact release metadata")
|
||||
);
|
||||
expect(stable.indexOf("Confirm the default branch contains the exact release metadata")).toBeLessThan(
|
||||
stable.indexOf("Create the stable CLI tag")
|
||||
);
|
||||
expect(stable).not.toContain("prerelease=false");
|
||||
});
|
||||
|
||||
it("summarises immutable pre-releases separately from stable versions awaiting promotion", () => {
|
||||
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
|
||||
|
||||
expect(workflow).toContain('if [[ "${VERSION}" == *-* ]]; then');
|
||||
expect(workflow).toContain(
|
||||
"Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action."
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
"After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
"Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release."
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
'if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then'
|
||||
);
|
||||
expect(workflow).toContain(
|
||||
"A stable version staged as a pre-release must use publish_cli=false so that the CLI latest and major-minor image tags do not advance before BRAT validation."
|
||||
expect(prerelease.stdout).toContain("Validate the exact published release with BRAT");
|
||||
expect(prerelease.stdout).toContain(
|
||||
"Mark this pull request ready and merge it into `common-library-package-boundary` with a merge commit"
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches the selected plug-in and CLI workflows explicitly", () => {
|
||||
it("keeps stable release instructions distinct from pre-release instructions", () => {
|
||||
const stable = runNode(releasePrBodyScript, ["1.0.0", "main"], makeTemporaryDirectory());
|
||||
|
||||
expect(stable.status, stable.stderr).toBe(0);
|
||||
expect(stable.stdout).toContain("prerelease=false");
|
||||
expect(stable.stdout).toContain(
|
||||
"Publish the GitHub Release as the latest stable release while keeping this pull request in draft"
|
||||
);
|
||||
expect(stable.stdout).not.toContain("as a pre-release without replacing");
|
||||
});
|
||||
|
||||
it("dispatches the plug-in workflow and lets the CLI tag trigger its own workflow", () => {
|
||||
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
|
||||
|
||||
expect(workflow).toContain("actions: write");
|
||||
expect(workflow).toContain('node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"');
|
||||
expect(workflow).toContain('git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"');
|
||||
expect(workflow).not.toContain("Tag already exists");
|
||||
expect(workflow).toContain("PUBLISH_CLI: ${{ inputs.publish_cli }}");
|
||||
expect(workflow).toContain('if [[ "${PUBLISH_CLI}" == "true" ]]; then');
|
||||
expect(workflow).toContain("gh workflow run cli-docker.yml");
|
||||
expect(workflow).toContain('--ref "${VERSION}-cli"');
|
||||
expect(workflow).toContain("--field dry_run=false");
|
||||
expect(workflow).toContain("--field force=false");
|
||||
expect(workflow).toContain("gh workflow run release.yml");
|
||||
expect(workflow).toContain("explicitly dispatched the CLI container workflow");
|
||||
expect(workflow).not.toContain("gh workflow run cli-docker.yml");
|
||||
expect(workflow).toContain("its tag event starts the container workflow");
|
||||
});
|
||||
|
||||
it("publishes only by explicit dispatch and validates the selected release", () => {
|
||||
|
||||
+3
-5
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"0.25.61": "1.7.2",
|
||||
"0.25.60": "1.7.2",
|
||||
"1.0.1": "0.9.12",
|
||||
"1.0.0": "0.9.7",
|
||||
"0.25.81": "1.7.2",
|
||||
"0.25.82": "1.7.2",
|
||||
"0.25.83": "1.7.2",
|
||||
@@ -8,9 +10,5 @@
|
||||
"1.0.0-beta.1": "1.7.2",
|
||||
"1.0.0-beta.2": "1.7.2",
|
||||
"1.0.0-beta.3": "1.7.2",
|
||||
"1.0.0-beta.4": "1.7.2",
|
||||
"1.0.0-beta.5": "1.7.2",
|
||||
"1.0.0-rc.0": "1.7.2",
|
||||
"1.0.0-rc.1": "1.7.2",
|
||||
"1.0.0": "1.7.2"
|
||||
"1.0.0-beta.4": "1.7.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user