mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-28 14:27:08 +00:00
Reorganise settings around common tasks
This commit is contained in:
@@ -102,11 +102,11 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
|
||||
|
||||
`test:contract:context:cli` builds the Node CLI and runs its existing Deno setup, put, read, list, information, remove, conflict-resolution, and revision workflow. `test:contract:context:obsidian` builds the plug-in and runs the real-Obsidian smoke test, including the Context inspection. These runtime scripts are local validation entry points and are not added to the default CI gate by this change.
|
||||
|
||||
`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from **Self-hosted LiveSync settings** → **Setup** on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios.
|
||||
`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from Quick Setup in either settings interface. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios.
|
||||
|
||||
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
|
||||
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it opens all 12 pages from the native page catalogue, searches globally for the Advanced control, captures the catalogue, search result, and Advanced page, and verifies that the value remains after the settings dialogue is closed and reopened. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
|
||||
The default runner uses the maintained pre-1.13 Obsidian fixture and therefore exercises the complete compatibility-review, mobile-layout, and imperative-settings path. To exercise only the native settings contract against an additional Obsidian 1.13-or-later installation, supply its executable and companion CLI explicitly:
|
||||
|
||||
@@ -117,7 +117,7 @@ E2E_OBSIDIAN_SETTINGS_ONLY=true \
|
||||
npm run test:e2e:obsidian:settings-ui
|
||||
```
|
||||
|
||||
The native run writes `settings-declarative-catalogue.png`, `settings-declarative-search.png`, and `settings-declarative-advanced.png` to `E2E_OBSIDIAN_DIAGNOSTICS_DIR`. All settings E2E scenarios open pages through the shared navigator in `runner/ui.ts`; scenario code must not select the legacy tab menu directly.
|
||||
The native run writes `settings-declarative-landing.png`, `settings-declarative-search.png`, `settings-declarative-advanced.png`, and `settings-declarative-landing-mobile.png` to `E2E_OBSIDIAN_DIAGNOSTICS_DIR`. All settings E2E scenarios open pages through the shared navigator in `runner/ui.ts`; scenario code must not select the legacy tab menu directly.
|
||||
|
||||
The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay.
|
||||
|
||||
@@ -235,7 +235,7 @@ Useful environment variables:
|
||||
- `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_SETTINGS_ONLY=true`: skip compatibility-review and mobile-layout coverage when running `settings-ui` against an additional Obsidian 1.13-or-later installation.
|
||||
- `E2E_OBSIDIAN_SETTINGS_ONLY=true`: skip compatibility-review and its dialogue-layout coverage when running `settings-ui` against an additional Obsidian 1.13-or-later installation; the native settings mobile-landing check still runs.
|
||||
- `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.
|
||||
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
|
||||
|
||||
@@ -70,9 +70,7 @@ async function applyObsidianMobileTestMode(
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const screenshotPath = join(
|
||||
outputDirectory,
|
||||
waitForLiveSync
|
||||
? "mobile-mode-transition.failure.png"
|
||||
: "mobile-mode-before-plugin-start.failure.png"
|
||||
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);
|
||||
@@ -108,7 +106,8 @@ export async function setObsidianMobileTestMode(port: number, enabled: boolean,
|
||||
|
||||
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");
|
||||
// Obsidian 1.13 moved and renamed the mobile close control; both selectors remain supported runtime contracts.
|
||||
const closeButton = container.locator(".modal-close-button, .modal-header-button").last();
|
||||
await assertLocatorWithinViewport(page, dialogue, { label });
|
||||
await assertNoHorizontalOverflow(page, dialogue, { label });
|
||||
await assertLocatorWithinSafeArea(page, dialogue, {
|
||||
|
||||
@@ -67,6 +67,19 @@ export async function captureObsidianDialogue(
|
||||
return await captureObsidianPage(port, filename, assertReady);
|
||||
}
|
||||
|
||||
/** Wait for a visible Obsidian dialogue, including one opened outside the settings window. */
|
||||
export async function waitForVisibleObsidianDialogue(page: Page, text: string, timeoutMs = 10_000): Promise<Locator> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
for (const candidate of page.context().pages()) {
|
||||
const dialogue = candidate.locator(".modal-container").filter({ hasText: text }).last();
|
||||
if (await dialogue.isVisible().catch(() => false)) return dialogue;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Obsidian did not open the expected dialogue: ${text}`);
|
||||
}
|
||||
|
||||
function declarativePageEntry(dialogue: Locator, name: string): Locator {
|
||||
return dialogue
|
||||
.locator(".setting-item.mod-navigable")
|
||||
@@ -85,7 +98,19 @@ function escapeRegExp(value: string): string {
|
||||
* owns that renderer difference for both supported settings implementations.
|
||||
*/
|
||||
export async function openLiveSyncSettings(page: Page, timeoutMs = 10_000): Promise<LiveSyncSettingsNavigator> {
|
||||
await page.evaluate(() => {
|
||||
let hostPage: Page | undefined;
|
||||
for (const candidate of page.context().pages()) {
|
||||
const hasSettingsHost = await candidate
|
||||
.evaluate(() => (globalThis as ObsidianSettingsHost).app?.setting !== undefined)
|
||||
.catch(() => false);
|
||||
if (hasSettingsHost) {
|
||||
hostPage = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hostPage === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
|
||||
await hostPage.evaluate(() => {
|
||||
const host = globalThis as ObsidianSettingsHost;
|
||||
const setting = host.app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
@@ -164,14 +189,14 @@ export async function openLiveSyncSettings(page: Page, timeoutMs = 10_000): Prom
|
||||
};
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
await page
|
||||
await hostPage
|
||||
.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianSettingsHost).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setTimeout(() => setting.close(), 0);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!page.isClosed() && !settingsPage.isClosed()) throw error;
|
||||
if (!hostPage.isClosed() && !settingsPage.isClosed()) throw error;
|
||||
});
|
||||
await Promise.race([
|
||||
dialogue.waitFor({ state: "hidden", timeout: timeoutMs }),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
captureObsidianDialogue,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
waitForVisibleObsidianDialogue,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
@@ -155,16 +156,23 @@ async function captureAndCloseIntro(filename: string, mobile: boolean): Promise<
|
||||
async function openOnboardingFromSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const liveSyncSettings = await settingsNavigator.openPage("Setup");
|
||||
const liveSyncSettings =
|
||||
settingsNavigator.renderer === "declarative"
|
||||
? (await settingsNavigator.returnToCatalogue(), settingsNavigator.dialogue)
|
||||
: await settingsNavigator.openPage("Quick Setup");
|
||||
|
||||
const onboardingSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }),
|
||||
});
|
||||
await onboardingSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await onboardingSetting
|
||||
.getByRole("button", { name: "Rerun Wizard", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await onboardingDialogue(settingsNavigator.page).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
await onboardingSetting.click({ timeout: uiTimeoutMs });
|
||||
} else {
|
||||
await onboardingSetting
|
||||
.getByRole("button", { name: "Rerun Wizard", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
await waitForVisibleObsidianDialogue(settingsNavigator.page, "Welcome to Self-hosted LiveSync", uiTimeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
preseedTrustedVaultState,
|
||||
waitForVisibleObsidianDialogue,
|
||||
withObsidianPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator } from "playwright";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
|
||||
const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true";
|
||||
@@ -49,20 +51,110 @@ type ObsidianTestApp = {
|
||||
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
|
||||
|
||||
const settingsPageNames = [
|
||||
"Change Log",
|
||||
"Setup",
|
||||
"General Settings",
|
||||
"Appearance",
|
||||
"Logging",
|
||||
"Extra menus",
|
||||
"Remote Configuration",
|
||||
"Sync Settings",
|
||||
"Maintenance",
|
||||
"Hatch",
|
||||
"Selector",
|
||||
"Customisation sync",
|
||||
"Hatch",
|
||||
"Advanced",
|
||||
"Power users",
|
||||
"Patches",
|
||||
"Maintenance",
|
||||
"Help and troubleshooting",
|
||||
"Change Log",
|
||||
] as const;
|
||||
|
||||
async function assertDeclarativeLandingOrder(root: Locator): Promise<void> {
|
||||
const labels = [
|
||||
"Quick Setup",
|
||||
"Synchronisation",
|
||||
"Remote Configuration",
|
||||
"Sync Settings",
|
||||
"General Settings",
|
||||
"Appearance",
|
||||
"Logging",
|
||||
"Extra menus",
|
||||
"📲 Set up other devices",
|
||||
"Maintenance and recovery",
|
||||
"Maintenance",
|
||||
"Hatch",
|
||||
"Extra features",
|
||||
"Selector",
|
||||
"Customisation sync",
|
||||
"Advanced settings",
|
||||
"Advanced",
|
||||
"Power users",
|
||||
"Patches",
|
||||
"Help and information",
|
||||
"Help and troubleshooting",
|
||||
"Change Log",
|
||||
];
|
||||
await root
|
||||
.locator(".vertical-tab-content:visible")
|
||||
.last()
|
||||
.evaluate((container, expectedLabels) => {
|
||||
const labelledElements = Array.from(
|
||||
container.querySelectorAll(".setting-item-heading, .setting-item-name, h1, h2, h3, h4")
|
||||
);
|
||||
const matchingElements = expectedLabels.map((label) => {
|
||||
const match = labelledElements.find((element) => element.textContent?.trim().endsWith(label));
|
||||
if (!match) throw new Error(`The settings landing page did not contain '${label}'.`);
|
||||
return match;
|
||||
});
|
||||
for (let index = 1; index < matchingElements.length; index++) {
|
||||
const previous = matchingElements[index - 1];
|
||||
const current = matchingElements[index];
|
||||
if (!(previous.compareDocumentPosition(current) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
||||
throw new Error(`The settings landing page was not ordered as ${expectedLabels.join(" -> ")}.`);
|
||||
}
|
||||
}
|
||||
}, labels);
|
||||
}
|
||||
|
||||
async function scrollDeclarativeLandingToTop(root: Locator): Promise<void> {
|
||||
const quickSetupHeading = root.locator(".setting-item-heading").filter({ hasText: "Quick Setup" }).first();
|
||||
await quickSetupHeading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await quickSetupHeading.scrollIntoViewIfNeeded();
|
||||
}
|
||||
|
||||
async function captureDeclarativeMobileLanding(): Promise<string | undefined> {
|
||||
const port = obsidianRemoteDebuggingPort();
|
||||
await setObsidianMobileTestMode(port, true, uiTimeoutMs);
|
||||
try {
|
||||
return await withObsidianPage(port, async (page) => {
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
if (settingsNavigator.renderer !== "declarative") {
|
||||
await settingsNavigator.close();
|
||||
return undefined;
|
||||
}
|
||||
await settingsNavigator.returnToCatalogue();
|
||||
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue);
|
||||
await assertDeclarativeLandingOrder(settingsNavigator.dialogue);
|
||||
const remoteConfiguration = settingsNavigator.dialogue
|
||||
.locator(".setting-item-name")
|
||||
.filter({ hasText: "Remote Configuration" })
|
||||
.first();
|
||||
await remoteConfiguration.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const path = `${diagnosticsDirectory}/settings-declarative-landing-mobile.png`;
|
||||
await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path });
|
||||
const remotePosition = await remoteConfiguration.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return { top: bounds.top, bottom: bounds.bottom, viewportHeight: window.innerHeight };
|
||||
});
|
||||
if (remotePosition.top < 0 || remotePosition.bottom > remotePosition.viewportHeight) {
|
||||
throw new Error("Remote Configuration was not visible at the top of the mobile settings landing page.");
|
||||
}
|
||||
await settingsNavigator.close();
|
||||
return path;
|
||||
});
|
||||
} finally {
|
||||
await setObsidianMobileTestMode(port, false, uiTimeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function resumePendingCompatibilityReviewForSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const review = page.locator(".modal-container").filter({
|
||||
@@ -307,7 +399,9 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
throw new Error("The desktop sleep preference must be enabled by default.");
|
||||
}
|
||||
|
||||
settingsPage = await settingsNavigator.openPage("Setup");
|
||||
settingsPage = await settingsNavigator.openPage(
|
||||
settingsNavigator.renderer === "declarative" ? "Extra menus" : "General Settings"
|
||||
);
|
||||
const advancedModeSetting = settingsPage.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Enable advanced features", { exact: true }),
|
||||
});
|
||||
@@ -367,16 +461,28 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
await settingsNavigator.returnToCatalogue();
|
||||
await settingsNavigator.dialogue
|
||||
.locator(".vertical-tab-content:visible")
|
||||
.last()
|
||||
.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue);
|
||||
await settingsNavigator.dialogue.screenshot({
|
||||
...settingsScreenshotOptions,
|
||||
path: `${diagnosticsDirectory}/settings-declarative-catalogue.png`,
|
||||
path: `${diagnosticsDirectory}/settings-declarative-landing.png`,
|
||||
});
|
||||
await assertDeclarativeLandingOrder(settingsNavigator.dialogue);
|
||||
const rerunOnboarding = settingsNavigator.dialogue
|
||||
.locator(".setting-item-name")
|
||||
.filter({ hasText: "Rerun Onboarding Wizard" })
|
||||
.first();
|
||||
await rerunOnboarding
|
||||
.locator("xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' setting-item ')][1]")
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
const onboarding = await waitForVisibleObsidianDialogue(
|
||||
page,
|
||||
"Welcome to Self-hosted LiveSync",
|
||||
uiTimeoutMs
|
||||
);
|
||||
await onboarding
|
||||
.getByRole("button", { name: "No, please take me back", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await onboarding.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
const search = settingsNavigator.dialogue.locator(".setting-search-container input");
|
||||
await search.fill("Memory cache size (by total items)");
|
||||
const searchResult = settingsNavigator.dialogue.locator(".setting-search-result-item").filter({
|
||||
@@ -433,6 +539,8 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
throw new Error(`The declarative Advanced value was not restored after reopening: ${restoredValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
await settingsNavigator.close();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -485,6 +593,8 @@ async function main(): Promise<void> {
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
}
|
||||
await verifyEffectiveSettings();
|
||||
const mobileLanding = await captureDeclarativeMobileLanding();
|
||||
if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`);
|
||||
console.log("Compatibility review and settings expose only effective user controls.");
|
||||
} finally {
|
||||
if (session) {
|
||||
|
||||
Reference in New Issue
Block a user