mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-25 22:12:59 +00:00
Keep unreadable revisions for explicit repair
This commit is contained in:
@@ -93,7 +93,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
|
||||
|
||||
`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, 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.
|
||||
`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.
|
||||
|
||||
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
|
||||
|
||||
@@ -136,6 +136,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`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 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.
|
||||
|
||||
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
|
||||
@@ -187,6 +189,7 @@ Useful environment variables:
|
||||
- `E2E_OBSIDIAN_SKIP_EXTRACT=true`: download the AppImage without extracting it.
|
||||
- `E2E_OBSIDIAN_SMOKE_TIMEOUT_MS`: smoke timeout in milliseconds.
|
||||
- `E2E_OBSIDIAN_DIALOG_TIMEOUT_MS`: timeout for a representative Svelte dialogue to mount, expose its principal controls, and close; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS`: timeout for each visible revision-repair control and result; default is 15 seconds.
|
||||
- `E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS`: timeout for the settings pane and its deletion controls to become visible; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
|
||||
|
||||
@@ -722,7 +722,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
|
||||
for (const label of [
|
||||
"Write logs into the file",
|
||||
"Recreate missing chunks for all files",
|
||||
"Recreate chunks for current Vault files",
|
||||
"Verify and repair all files",
|
||||
]) {
|
||||
await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({
|
||||
@@ -730,7 +730,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
await liveSyncSettings.getByRole("button", { name: "Recreate all", exact: true }).waitFor({
|
||||
await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
@@ -739,7 +739,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await liveSyncSettings
|
||||
.locator(".setting-item-name", { hasText: "Recreate missing chunks for all files" })
|
||||
.locator(".setting-item-name", { hasText: "Recreate chunks for current Vault files" })
|
||||
.scrollIntoViewIfNeeded();
|
||||
return liveSyncSettings;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const testSteps: Step[] = [
|
||||
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
|
||||
{ name: "onboarding invitation", args: ["run", "test:e2e:obsidian:onboarding-invitation"] },
|
||||
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
|
||||
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
|
||||
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
|
||||
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
|
||||
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const path = "revision-repair.md";
|
||||
const baseContent = "Revision repair\n\nShared base.\n";
|
||||
const branchContents = [
|
||||
`Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`,
|
||||
`Revision repair\n\nRight branch.\n${"R".repeat(4096)}\n`,
|
||||
] as const;
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS ?? 15000);
|
||||
|
||||
type BrokenRevisionFixture = {
|
||||
winnerRevision: string;
|
||||
conflictRevision: string;
|
||||
missingChunkId: string;
|
||||
};
|
||||
|
||||
type RevisionTree = {
|
||||
winnerRevision: string;
|
||||
conflictRevisions: string[];
|
||||
};
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & {
|
||||
app?: {
|
||||
setting?: ObsidianSettingsController;
|
||||
};
|
||||
};
|
||||
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const content=${JSON.stringify(baseContent)};`,
|
||||
"let file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) file=await app.vault.create(path,content);",
|
||||
"await app.workspace.getLeaf(false).openFile(file);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function createBrokenConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
baseRevision: string
|
||||
): Promise<BrokenRevisionFixture> {
|
||||
return await evalObsidianJson<BrokenRevisionFixture>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const baseRevision=${JSON.stringify(baseRevision)};`,
|
||||
`const contents=${JSON.stringify(branchContents)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"for(const [index,content] of contents.entries()){",
|
||||
" const blob=new Blob([content],{type:'text/plain'});",
|
||||
" const now=Date.now()+index;",
|
||||
" const result=await core.localDatabase.putDBEntry({",
|
||||
" _id:id,path,data:blob,ctime:now,mtime:now,",
|
||||
" size:(await blob.arrayBuffer()).byteLength,children:[],",
|
||||
" datatype:'plain',type:'plain',eden:{},",
|
||||
" },false,baseRevision);",
|
||||
" if(!result?.ok) throw new Error(`Could not create repair conflict: ${path}`);",
|
||||
"}",
|
||||
"const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});",
|
||||
"const conflictRevision=tree._conflicts?.[0];",
|
||||
"if(!tree._rev||!conflictRevision){",
|
||||
" throw new Error(`Repair fixture did not produce two live revisions: ${path}`);",
|
||||
"}",
|
||||
"const conflict=await core.localDatabase.localDatabase.get(id,{rev:conflictRevision});",
|
||||
"const embedded=new Set(Object.keys(conflict.eden??{}));",
|
||||
"const missingChunkId=(conflict.children??[]).find((child)=>!embedded.has(child));",
|
||||
"if(!missingChunkId){",
|
||||
" throw new Error(`Repair fixture did not create an independent chunk: ${conflictRevision}`);",
|
||||
"}",
|
||||
"const chunk=await core.localDatabase.localDatabase.get(missingChunkId);",
|
||||
"await core.localDatabase.localDatabase.remove(chunk);",
|
||||
"core.localDatabase.clearCaches();",
|
||||
"const unreadable=await core.localDatabase.getDBEntry(path,{rev:conflictRevision},false,true,true);",
|
||||
"if(unreadable!==false){",
|
||||
" throw new Error(`The selected revision remained readable after its chunk was removed: ${conflictRevision}`);",
|
||||
"}",
|
||||
"return JSON.stringify({",
|
||||
" winnerRevision:tree._rev,",
|
||||
" conflictRevision,",
|
||||
" missingChunkId,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Promise<RevisionTree> {
|
||||
return await evalObsidianJson<RevisionTree>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});",
|
||||
"return JSON.stringify({",
|
||||
" winnerRevision:tree._rev,",
|
||||
" conflictRevisions:tree._conflicts??[],",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"core.localDatabase.clearCaches();",
|
||||
"await core.services.conflict.queueCheckFor(path);",
|
||||
"await core.services.conflict.ensureAllProcessed();",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
}
|
||||
const cliBinary = cli.binary;
|
||||
const vault = await createTemporaryVault("obsidian-livesync-revision-repair-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: {
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
remoteType: "",
|
||||
couchDB_URI: "",
|
||||
couchDB_DBNAME: "",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
notifyThresholdOfRemoteStorageSize: -1,
|
||||
periodicReplication: false,
|
||||
syncAfterMerge: false,
|
||||
syncOnEditorSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnSave: false,
|
||||
syncOnStart: false,
|
||||
disableMarkdownAutoMerge: true,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
useEden: false,
|
||||
},
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await createAndOpenBaseFile(cliBinary, session.cliEnv);
|
||||
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||
const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev);
|
||||
|
||||
await requestConflictCheck(cliBinary, session.cliEnv);
|
||||
const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (
|
||||
afterAutomaticCheck.winnerRevision !== fixture.winnerRevision ||
|
||||
!afterAutomaticCheck.conflictRevisions.includes(fixture.conflictRevision)
|
||||
) {
|
||||
throw new Error(
|
||||
`Automatic conflict checking discarded the unreadable revision: ${JSON.stringify({
|
||||
fixture,
|
||||
afterAutomaticCheck,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const settings = page.locator(".sls-setting");
|
||||
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("Verify and repair all files", { exact: true }),
|
||||
});
|
||||
await verifySetting.getByRole("button", { name: "Verify all", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const card = settings.locator(".sls-repair-result").filter({ hasText: path });
|
||||
await card.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const brokenRevision = card
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision });
|
||||
await brokenRevision
|
||||
.getByText(/Unreadable on this device/u)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if ((await card.locator(".sls-repair-revision").count()) !== 2) {
|
||||
throw new Error("Verify and Repair did not render the winner and conflict revision separately.");
|
||||
}
|
||||
|
||||
await brokenRevision.getByRole("button", { name: "Retry reading revision", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
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 (!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");
|
||||
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 unreadable revision" }),
|
||||
});
|
||||
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await confirmation.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
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");
|
||||
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 unreadable revision" }),
|
||||
});
|
||||
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ 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
|
||||
) {
|
||||
throw new Error(
|
||||
`Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({
|
||||
fixture,
|
||||
afterDiscard,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"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 screenshot: ${screenshot}`);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
}
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const focusedScenarios = new Set([
|
||||
"smoke",
|
||||
"onboarding-invitation",
|
||||
"dialog-mounts",
|
||||
"revision-repair",
|
||||
"settings-ui",
|
||||
"review-harness",
|
||||
"p2p-pane",
|
||||
|
||||
Reference in New Issue
Block a user