mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-17 18:16:01 +00:00
feat: distinguish remote operation and request activity
This commit is contained in:
@@ -2,8 +2,24 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { withObsidianPage } from "./ui.ts";
|
||||
import {
|
||||
REMOTE_OPERATION_ACTIVITY_ICON,
|
||||
REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
} from "../../../src/modules/features/RemoteActivityStatus.ts";
|
||||
|
||||
export const REMOTE_ACTIVITY_E2E_STATE_KEY = "__livesyncE2ERemoteActivity";
|
||||
export const REMOTE_ACTIVITY_GATE_KIND = {
|
||||
chunkFetch: "chunk-fetch",
|
||||
oneShot: "one-shot",
|
||||
trackedRequest: "tracked-request",
|
||||
} as const;
|
||||
export const REMOTE_ACTIVITY_EXPECTED_STATE = {
|
||||
chunkFetchActive: "chunk-fetch-active",
|
||||
finiteReplicationActive: "finite-replication-active",
|
||||
idle: "idle",
|
||||
trackedRequestActive: "tracked-request-active",
|
||||
} as const;
|
||||
export type RemoteActivityGateKind = (typeof REMOTE_ACTIVITY_GATE_KIND)[keyof typeof REMOTE_ACTIVITY_GATE_KIND];
|
||||
|
||||
export type RemoteActivitySnapshot = {
|
||||
boundedRemoteActivityCount: number;
|
||||
@@ -11,14 +27,17 @@ export type RemoteActivitySnapshot = {
|
||||
gateDone?: boolean;
|
||||
gateEntered?: boolean;
|
||||
gateError?: string;
|
||||
gateKind?: string;
|
||||
gateKind?: RemoteActivityGateKind;
|
||||
requestCount: number;
|
||||
remoteOperationIndicatorVisible: boolean;
|
||||
remoteRequestIndicatorVisible: boolean;
|
||||
responseCount: number;
|
||||
statusBarFound: boolean;
|
||||
statusBarText: string;
|
||||
};
|
||||
|
||||
export type ExpectedRemoteActivityState = "chunk-fetch-active" | "finite-replication-active" | "idle";
|
||||
export type ExpectedRemoteActivityState =
|
||||
(typeof REMOTE_ACTIVITY_EXPECTED_STATE)[keyof typeof REMOTE_ACTIVITY_EXPECTED_STATE];
|
||||
|
||||
type RuntimeCounter = { value?: number };
|
||||
|
||||
@@ -39,7 +58,7 @@ type RuntimeGate = {
|
||||
done?: boolean;
|
||||
entered?: boolean;
|
||||
error?: string;
|
||||
kind?: string;
|
||||
kind?: RemoteActivityGateKind;
|
||||
};
|
||||
|
||||
type RendererGlobals = typeof globalThis & {
|
||||
@@ -52,7 +71,7 @@ type RendererGlobals = typeof globalThis & {
|
||||
|
||||
async function readRemoteActivitySnapshotFromPage(page: Page): Promise<RemoteActivitySnapshot> {
|
||||
return await page.evaluate(
|
||||
({ pluginId, stateKey }) => {
|
||||
({ operationIcon, pluginId, requestIcon, stateKey }) => {
|
||||
const globals = globalThis as RendererGlobals;
|
||||
const core = globals.app?.plugins?.plugins?.[pluginId]?.core;
|
||||
if (!core) throw new Error(`Obsidian plug-in is not loaded: ${pluginId}`);
|
||||
@@ -68,12 +87,23 @@ async function readRemoteActivitySnapshotFromPage(page: Page): Promise<RemoteAct
|
||||
gateError: gate?.error,
|
||||
gateKind: gate?.kind,
|
||||
requestCount: Number(core.services?.API?.requestCount?.value ?? -1),
|
||||
remoteOperationIndicatorVisible: statusBars.some((element) =>
|
||||
(element.textContent ?? "").includes(operationIcon)
|
||||
),
|
||||
remoteRequestIndicatorVisible: statusBars.some((element) =>
|
||||
(element.textContent ?? "").includes(requestIcon)
|
||||
),
|
||||
responseCount: Number(core.services?.API?.responseCount?.value ?? -1),
|
||||
statusBarFound: statusBars.length > 0,
|
||||
statusBarText: statusBars.map((element) => element.textContent ?? "").join("\n"),
|
||||
} satisfies RemoteActivitySnapshot;
|
||||
},
|
||||
{ pluginId: "obsidian-livesync", stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY }
|
||||
{
|
||||
operationIcon: REMOTE_OPERATION_ACTIVITY_ICON,
|
||||
pluginId: "obsidian-livesync",
|
||||
requestIcon: REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,7 +135,7 @@ export async function waitForRemoteActivityState(
|
||||
try {
|
||||
return await withObsidianPage(port, async (page) => {
|
||||
await page.waitForFunction(
|
||||
({ expectedState, pluginId, stateKey }) => {
|
||||
({ expectedState, expectedStates, gateKinds, operationIcon, pluginId, requestIcon, stateKey }) => {
|
||||
const globals = globalThis as RendererGlobals;
|
||||
const core = globals.app?.plugins?.plugins?.[pluginId]?.core;
|
||||
if (!core) return false;
|
||||
@@ -117,31 +147,58 @@ export async function waitForRemoteActivityState(
|
||||
const finite = Number(core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1);
|
||||
const requests = Number(core.services?.API?.requestCount?.value ?? -1);
|
||||
const responses = Number(core.services?.API?.responseCount?.value ?? -1);
|
||||
const iconVisible = statusBarText.includes("📲");
|
||||
const operationIconVisible = statusBarText.includes(operationIcon);
|
||||
const requestIconVisible = statusBarText.includes(requestIcon);
|
||||
const trackedRequests = Math.max(0, requests - responses);
|
||||
|
||||
if (expectedState === "finite-replication-active") {
|
||||
if (expectedState === expectedStates.finiteReplicationActive) {
|
||||
return (
|
||||
gate?.kind === "one-shot" &&
|
||||
gate?.kind === gateKinds.oneShot &&
|
||||
gate.entered === true &&
|
||||
bounded > 0 &&
|
||||
finite > 0 &&
|
||||
iconVisible
|
||||
operationIconVisible &&
|
||||
!requestIconVisible &&
|
||||
trackedRequests === 0
|
||||
);
|
||||
}
|
||||
if (expectedState === "chunk-fetch-active") {
|
||||
if (expectedState === expectedStates.chunkFetchActive) {
|
||||
return (
|
||||
gate?.kind === "chunk-fetch" &&
|
||||
gate?.kind === gateKinds.chunkFetch &&
|
||||
gate.entered === true &&
|
||||
bounded > 0 &&
|
||||
finite === 0 &&
|
||||
iconVisible
|
||||
operationIconVisible &&
|
||||
!requestIconVisible &&
|
||||
trackedRequests === 0
|
||||
);
|
||||
}
|
||||
return bounded === 0 && finite === 0 && requests === responses && !iconVisible;
|
||||
if (expectedState === expectedStates.trackedRequestActive) {
|
||||
return (
|
||||
gate?.kind === gateKinds.trackedRequest &&
|
||||
gate.entered === true &&
|
||||
bounded === 0 &&
|
||||
finite === 0 &&
|
||||
trackedRequests > 0 &&
|
||||
!operationIconVisible &&
|
||||
statusBarText.includes(`${requestIcon}${trackedRequests}`)
|
||||
);
|
||||
}
|
||||
return (
|
||||
bounded === 0 &&
|
||||
finite === 0 &&
|
||||
requests === responses &&
|
||||
!operationIconVisible &&
|
||||
!requestIconVisible
|
||||
);
|
||||
},
|
||||
{
|
||||
expectedState: expected,
|
||||
expectedStates: REMOTE_ACTIVITY_EXPECTED_STATE,
|
||||
gateKinds: REMOTE_ACTIVITY_GATE_KIND,
|
||||
operationIcon: REMOTE_OPERATION_ACTIVITY_ICON,
|
||||
pluginId: "obsidian-livesync",
|
||||
requestIcon: REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY,
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { evalObsidianJson } from "./cli.ts";
|
||||
import { REMOTE_ACTIVITY_E2E_STATE_KEY } from "./remoteActivity.ts";
|
||||
import {
|
||||
REMOTE_ACTIVITY_E2E_STATE_KEY,
|
||||
REMOTE_ACTIVITY_GATE_KIND,
|
||||
type RemoteActivityGateKind,
|
||||
} from "./remoteActivity.ts";
|
||||
|
||||
export type HeldRemoteActivityResult = {
|
||||
done: boolean;
|
||||
entered: boolean;
|
||||
error?: string;
|
||||
kind: "chunk-fetch" | "one-shot";
|
||||
kind: RemoteActivityGateKind;
|
||||
requestedIds?: string[];
|
||||
result?: boolean;
|
||||
resultCount?: number;
|
||||
@@ -27,7 +31,7 @@ export async function startHeldOneShotReplication(cliBinary: string, env: NodeJS
|
||||
"const original=replicator.openReplication;",
|
||||
"let releaseGate;",
|
||||
"const gate=new Promise((resolve)=>{releaseGate=resolve;});",
|
||||
"const state={kind:'one-shot',entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};",
|
||||
`const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.oneShot)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`,
|
||||
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
|
||||
"state.restore=()=>{replicator.openReplication=original;};",
|
||||
"host[stateKey]=state;",
|
||||
@@ -74,7 +78,7 @@ export async function startHeldChunkFetch(cliBinary: string, env: NodeJS.Process
|
||||
"let resolveDone;",
|
||||
"const gate=new Promise((resolve)=>{releaseGate=resolve;});",
|
||||
"const donePromise=new Promise((resolve)=>{resolveDone=resolve;});",
|
||||
"const state={kind:'chunk-fetch',entered:false,done:false,released:false,error:undefined,resultCount:undefined,requestedIds:undefined,promise:donePromise,release:undefined,restore:undefined};",
|
||||
`const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.chunkFetch)},entered:false,done:false,released:false,error:undefined,resultCount:undefined,requestedIds:undefined,promise:donePromise,release:undefined,restore:undefined};`,
|
||||
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
|
||||
"state.restore=()=>{replicator.fetchRemoteChunks=original;};",
|
||||
"host[stateKey]=state;",
|
||||
@@ -103,6 +107,51 @@ export async function startHeldChunkFetch(cliBinary: string, env: NodeJS.Process
|
||||
);
|
||||
}
|
||||
|
||||
export async function startHeldTrackedRequest(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<{ started: boolean }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const stateKey=${stateKeySource};`,
|
||||
"const host=globalThis;",
|
||||
"if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const remote=core.services.remote;",
|
||||
"const api=core.services.API;",
|
||||
"const settings=core.services.setting.currentSettings();",
|
||||
"const original=api.webCompatFetch;",
|
||||
"let releaseGate;",
|
||||
"const gate=new Promise((resolve)=>{releaseGate=resolve;});",
|
||||
`const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.trackedRequest)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`,
|
||||
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
|
||||
"state.restore=()=>{api.webCompatFetch=original;};",
|
||||
"host[stateKey]=state;",
|
||||
"api.webCompatFetch=async function(...args){",
|
||||
"state.entered=true;",
|
||||
"await gate;",
|
||||
"return await original.apply(this,args);",
|
||||
"};",
|
||||
"state.promise=(async()=>{",
|
||||
"try{",
|
||||
"const base=String(settings.couchDB_URI).replace(/\\/$/,'');",
|
||||
"const database=encodeURIComponent(settings.couchDB_DBNAME);",
|
||||
"const credentials=btoa(`${settings.couchDB_USER}:${settings.couchDB_PASSWORD}`);",
|
||||
"const response=await remote.performFetch(`${base}/${database}/_all_docs?limit=0`,{headers:{Authorization:`Basic ${credentials}`}});",
|
||||
"state.result=response.ok;",
|
||||
"}catch(error){",
|
||||
"state.error=error instanceof Error?error.message:String(error);",
|
||||
"}finally{",
|
||||
"state.restore();",
|
||||
"state.done=true;",
|
||||
"}",
|
||||
"})();",
|
||||
"return JSON.stringify({started:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export async function finishHeldRemoteActivity(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv
|
||||
|
||||
Reference in New Issue
Block a user