Prepare the 1.0 compatibility review flow

This commit is contained in:
vorotamoroz
2026-07-19 04:34:08 +00:00
parent 52138bf7a5
commit ed3f81e9f9
42 changed files with 5524 additions and 4119 deletions
@@ -188,6 +188,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
override onOpen() {
this.component.load();
const { contentEl } = this;
contentEl.closest(".modal-container")?.classList.add("livesync-message-box-container");
this.titleEl.setText(this.title);
const div = contentEl.createDiv();
div.setCssStyles({
@@ -14,7 +14,6 @@ import {
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay, isObjectDifferent, sizeToHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { versionNumberString2Number } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { checkSyncInfo } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import { testCrypt } from "octagonal-wheels/encryption/encryption";
@@ -34,7 +33,6 @@ import {
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import { fireAndForget, yieldNextAnimationFrame } from "octagonal-wheels/promises";
import { confirmWithMessage } from "@/modules/coreObsidian/UILib/dialogs.ts";
import { EVENT_REQUEST_RELOAD_SETTING_TAB, eventHub } from "@/common/events.ts";
import { paneChangeLog } from "./PaneChangeLog.ts";
import {
@@ -417,11 +415,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
}
//@ts-ignore
manifestVersion: string = MANIFEST_VERSION || "-";
lastVersion = ~~(versionNumberString2Number(this.manifestVersion) / 1000);
screenElements: { [key: string]: HTMLElement[] } = {};
changeDisplay(screen: string) {
for (const k in this.screenElements) {
@@ -623,7 +616,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
OPTION_ONLY_SETTING,
OPTION_CANCEL,
];
const result = await confirmWithMessage(this.plugin, title, note, buttons, OPTION_CANCEL, 0);
const result = await this.core.confirm.confirmWithMessage(title, note, buttons, OPTION_CANCEL);
if (result == OPTION_CANCEL) return;
if (result == OPTION_FETCH) {
if (!(await this.checkWorkingPassphrase())) {
@@ -829,18 +822,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
void yieldNextAnimationFrame().then(() => {
if (this.selectedScreen == "") {
if (this.lastVersion != this.editingSettings.lastReadUpdates) {
if (this.editingSettings.isConfigured) {
changeDisplay("100");
} else {
changeDisplay("110");
}
if (this.isAnySyncEnabled()) {
changeDisplay("20");
} else {
if (this.isAnySyncEnabled()) {
changeDisplay("20");
} else {
changeDisplay("110");
}
changeDisplay("110");
}
} else {
changeDisplay(this.selectedScreen);
@@ -1,61 +1,11 @@
import { MarkdownRenderer } from "@/deps.ts";
import { versionNumberString2Number } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import { fireAndForget } from "octagonal-wheels/promises";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import { visibleOnly } from "./SettingPane.ts";
//@ts-ignore
const manifestVersion: string = MANIFEST_VERSION || "-";
//@ts-ignore
const updateInformation: string = UPDATE_INFO || "";
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement): void {
const cx = this.createEl(
paneEl,
"div",
{
cls: "op-warn-info",
},
undefined,
visibleOnly(() => !this.isConfiguredAs("versionUpFlash", ""))
);
this.createEl(
cx,
"div",
{
text: this.editingSettings.versionUpFlash,
},
undefined
);
this.createEl(cx, "button", { text: $msg("obsidianLiveSyncSettingTab.btnGotItAndUpdated") }, (e) => {
e.addClass("mod-cta");
e.addEventListener("click", () => {
fireAndForget(async () => {
this.editingSettings.versionUpFlash = "";
await this.saveAllDirtySettings();
});
});
});
const informationDivEl = this.createEl(paneEl, "div", { text: "" });
const tmpDiv = createDiv();
// tmpDiv.addClass("sls-header-button");
tmpDiv.addClass("op-warn-info");
tmpDiv.createEl("p", { text: $msg("obsidianLiveSyncSettingTab.msgNewVersionNote") });
const readEverythingButton = tmpDiv.createEl("button", {
text: $msg("obsidianLiveSyncSettingTab.optionOkReadEverything"),
});
if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) {
const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
readEverythingButton.addEventListener("click", () => {
fireAndForget(async () => {
this.editingSettings.lastReadUpdates = lastVersion;
await this.saveAllDirtySettings();
informationButtonDiv.remove();
});
});
}
fireAndForget(() =>
MarkdownRenderer.render(this.plugin.app, updateInformation, informationDivEl, "/", this.lifetimeComponent)
);
+5 -27
View File
@@ -1,5 +1,9 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, VER, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
EVENT_LAYOUT_READY,
EVENT_PLUGIN_LOADED,
@@ -8,13 +12,11 @@ import {
eventHub,
} from "@/common/events.ts";
import { $msg, setLang } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import { versionNumberString2Number } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker/bgWorker";
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
export class ModuleLiveSyncMain extends AbstractModule {
async _onLiveSyncReady() {
@@ -97,30 +99,6 @@ export class ModuleLiveSyncMain extends AbstractModule {
this._log($msg("moduleLiveSyncMain.logPluginInitCancelled"), LOG_LEVEL_NOTICE);
return false;
}
const lsKey = "obsidian-live-sync-ver" + this.services.vault.getVaultName();
const last_version = compatGlobal.localStorage.getItem(lsKey);
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
if (lastVersion > this.settings.lastReadUpdates && this.settings.isConfigured) {
this._log($msg("moduleLiveSyncMain.logReadChangelog"), LOG_LEVEL_NOTICE);
}
// //@ts-ignore
// if (this.isMobile) {
// this.settings.disableRequestURI = true;
// }
if (last_version && Number(last_version) < VER) {
this.settings.liveSync = false;
this.settings.syncOnSave = false;
this.settings.syncOnEditorSave = false;
this.settings.syncOnStart = false;
this.settings.syncOnFileOpen = false;
this.settings.syncAfterMerge = false;
this.settings.periodicReplication = false;
this.settings.versionUpFlash = $msg("moduleLiveSyncMain.logVersionUpdate");
await this.saveSettings();
}
compatGlobal.localStorage.setItem(lsKey, `${VER}`);
await this.services.database.openDatabase({
databaseEvents: this.services.databaseEvents,
replicator: this.services.replicator,
@@ -54,6 +54,14 @@
max-height: 100%;
}
:global(body.is-mobile .livesync-svelte-dialog-container .dialog-host > .button-group) {
background: var(--modal-background, var(--background-primary));
bottom: 0;
padding-bottom: 1px;
position: sticky;
z-index: 1;
}
.dialog-host {
padding: 20px;
gap: 0.5em;
+143 -25
View File
@@ -1,18 +1,19 @@
import { type App, type Plugin, Notice } from "@/deps";
import { scheduleTask, memoIfNotExist, memoObject, retrieveMemoObject, disposeMemoObject } from "@/common/utils";
import { EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import { confirmAction, pickOne, promptPassword, promptText } from "@vrtmrz/obsidian-plugin-kit";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import {
askYesNo,
askString,
confirmWithMessageWithWideButton,
askSelectString,
confirmWithMessage,
confirmWithMessage as confirmWithLegacyMessage,
} from "@/modules/coreObsidian/UILib/dialogs";
export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceContext> implements Confirm {
private _context: T;
private readonly dialogueController = new AbortController();
private readonly popupKeys = new Set<string>();
get _app(): App {
return this._context.app;
}
@@ -21,12 +22,58 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
}
constructor(context: T) {
this._context = context;
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
this.dialogueController.abort();
for (const popupKey of this.popupKeys) {
this.closePopup(popupKey);
}
});
}
askYesNo(message: string): Promise<"yes" | "no"> {
return askYesNo(this._app, message);
private get dialogueLifecycle() {
return { signal: this.dialogueController.signal };
}
askString(title: string, key: string, placeholder: string, isPassword: boolean = false): Promise<string | false> {
return askString(this._app, title, key, placeholder, isPassword);
private hasCountdown(timeout: number | undefined): timeout is number {
return timeout !== undefined && timeout > 0;
}
async askYesNo(message: string): Promise<"yes" | "no"> {
const result = await confirmAction(
this._app,
{
title: $msg("moduleInputUIObsidian.defaultTitleConfirmation"),
message,
actions: ["yes", "no"] as const,
labels: {
yes: $msg("moduleInputUIObsidian.optionYes"),
no: $msg("moduleInputUIObsidian.optionNo"),
},
defaultAction: "no",
sourcePath: "/",
},
this.dialogueLifecycle
);
return result === "yes" ? "yes" : "no";
}
async askString(
title: string,
key: string,
placeholder: string,
isPassword: boolean = false
): Promise<string | false> {
const prompt = isPassword ? promptPassword : promptText;
const result = await prompt(
this._app,
{
title,
label: key,
placeholder,
},
this.dialogueLifecycle
);
return result ?? false;
}
async askYesNoDialog(
@@ -37,6 +84,21 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
const yesLabel = $msg("moduleInputUIObsidian.optionYes");
const noLabel = $msg("moduleInputUIObsidian.optionNo");
const defaultOption = opt.defaultOption === "Yes" ? yesLabel : noLabel;
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
actions: ["Yes", "No"] as const,
labels: { Yes: yesLabel, No: noLabel },
defaultAction: opt.defaultOption === "Yes" ? "Yes" : "No",
sourcePath: "/",
},
this.dialogueLifecycle
);
return result === "Yes" ? "yes" : "no";
}
const ret = await confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
@@ -48,16 +110,39 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
return ret === yesLabel ? "yes" : "no";
}
askSelectString(message: string, items: string[]): Promise<string> {
return askSelectString(this._app, message, items);
async askSelectString(message: string, items: string[]): Promise<string> {
const result = await pickOne(
this._app,
{
items,
getText: (item) => item,
placeholder: message,
},
this.dialogueLifecycle
);
return result ?? "";
}
askSelectStringDialogue<T extends readonly string[]>(
async askSelectStringDialogue<T extends readonly string[]>(
message: string,
buttons: T,
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect");
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
actions: buttons,
defaultAction: opt.defaultAction,
sourcePath: "/",
},
this.dialogueLifecycle
);
return result ?? false;
}
return confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
@@ -68,7 +153,14 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
);
}
askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void) {
askInPopup(
key: string,
dialogText: string,
anchorCallback: (anchor: HTMLAnchorElement) => void,
durationMs: number = 20000
) {
const popupKey = "popup-" + key;
this.popupKeys.add(popupKey);
const fragment = createFragment((doc) => {
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
doc.createSpan(undefined, (a) => {
@@ -76,36 +168,62 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
a.appendChild(
a.createEl("a", undefined, (anchor) => {
anchorCallback(anchor);
anchor.addEventListener("click", () => this.closePopup(popupKey));
})
);
a.appendText(afterText);
});
});
const popupKey = "popup-" + key;
scheduleTask(popupKey, 1000, async () => {
if (this.dialogueController.signal.aborted) {
this.popupKeys.delete(popupKey);
return;
}
const popup = await memoIfNotExist(popupKey, () => new Notice(fragment, 0));
const isShown = popup?.noticeEl?.isShown();
if (!isShown) {
memoObject(popupKey, new Notice(fragment, 0));
}
scheduleTask(popupKey + "-close", 20000, () => {
const popup = retrieveMemoObject<Notice>(popupKey);
if (!popup) return;
if (popup?.noticeEl?.isShown()) {
popup.hide();
}
disposeMemoObject(popupKey);
});
scheduleTask(popupKey + "-close", durationMs, () => this.closePopup(popupKey));
});
}
confirmWithMessage(
private closePopup(popupKey: string) {
const popup = retrieveMemoObject<Notice>(popupKey);
if (!popup) {
this.popupKeys.delete(popupKey);
return;
}
if (popup.noticeEl?.isShown()) {
popup.hide();
}
disposeMemoObject(popupKey);
this.popupKeys.delete(popupKey);
}
async confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number
timeout?: number,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
return confirmWithMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout);
if (this.hasCountdown(timeout)) {
return confirmWithLegacyMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout);
}
const result = await confirmAction(
this._app,
{
title,
message: contentMd,
actions: buttons,
defaultAction,
sourcePath: "/",
...(actionLayout === undefined ? {} : { actionLayout }),
},
this.dialogueLifecycle
);
return result ?? false;
}
}
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
confirmAction: vi.fn(),
pickOne: vi.fn(),
promptPassword: vi.fn(),
promptText: vi.fn(),
legacyAskSelectString: vi.fn(),
legacyAskString: vi.fn(),
legacyAskYesNo: vi.fn(),
legacyConfirm: vi.fn(),
legacyWideConfirm: vi.fn(),
}));
vi.mock("@vrtmrz/obsidian-plugin-kit", () => ({
confirmAction: mocks.confirmAction,
pickOne: mocks.pickOne,
promptPassword: mocks.promptPassword,
promptText: mocks.promptText,
}));
vi.mock("@/modules/coreObsidian/UILib/dialogs", () => ({
askSelectString: mocks.legacyAskSelectString,
askString: mocks.legacyAskString,
askYesNo: mocks.legacyAskYesNo,
confirmWithMessage: mocks.legacyConfirm,
confirmWithMessageWithWideButton: mocks.legacyWideConfirm,
}));
vi.mock("@/deps", () => ({
Notice: class {},
}));
import { EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { memoObject, retrieveMemoObject } from "@/common/utils";
import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { ObsidianConfirm } from "./ObsidianConfirm";
import type { ObsidianServiceContext } from "./ObsidianServiceContext";
function createConfirm() {
const app = { id: "app" };
const plugin = { app };
const events = createLiveSyncEventHub();
const context = { app, plugin, events } as unknown as ObsidianServiceContext;
return { confirm: new ObsidianConfirm(context), events, app, plugin };
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("ObsidianConfirm Fancy Kit adapter", () => {
it("uses owner-bound Kit prompts and preserves cancellation and empty input", async () => {
const { confirm, app } = createConfirm();
mocks.promptText.mockResolvedValueOnce(null);
mocks.promptPassword.mockResolvedValueOnce("");
await expect(confirm.askString("Name", "Device name", "New Remote")).resolves.toBe(false);
await expect(confirm.askString("Secret", "Passphrase", "Enter it", true)).resolves.toBe("");
expect(mocks.promptText).toHaveBeenCalledWith(
app,
{
title: "Name",
label: "Device name",
placeholder: "New Remote",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.promptPassword).toHaveBeenCalledWith(
app,
{
title: "Secret",
label: "Passphrase",
placeholder: "Enter it",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskString).not.toHaveBeenCalled();
});
it("uses Kit for untimed yes/no and typed selection while preserving dismissed results", async () => {
const { confirm, app } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("yes");
mocks.pickOne.mockResolvedValueOnce("Beta").mockResolvedValueOnce(null);
await expect(confirm.askYesNo("Continue?")).resolves.toBe("yes");
await expect(confirm.askSelectString("Target", ["Alpha", "Beta"])).resolves.toBe("Beta");
await expect(confirm.askSelectString("Target", ["Alpha"])).resolves.toBe("");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
expect.objectContaining({
message: "Continue?",
actions: ["yes", "no"],
defaultAction: "no",
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.pickOne).toHaveBeenCalledWith(
app,
expect.objectContaining({
items: ["Alpha", "Beta"],
getText: expect.any(Function),
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskYesNo).not.toHaveBeenCalled();
expect(mocks.legacyAskSelectString).not.toHaveBeenCalled();
});
it("uses Kit for untimed action dialogues and retains the legacy countdown path", async () => {
const { confirm, app, plugin } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("Apply").mockResolvedValueOnce("Yes");
mocks.legacyConfirm.mockResolvedValueOnce("Cancel");
mocks.legacyWideConfirm.mockResolvedValueOnce("No");
await expect(
confirm.confirmWithMessage("Review", "**Apply?**", ["Apply", "Cancel"], "Cancel", undefined, "vertical")
).resolves.toBe("Apply");
await expect(confirm.askYesNoDialog("Continue?", { title: "Question", defaultOption: "Yes" })).resolves.toBe(
"yes"
);
await expect(confirm.confirmWithMessage("Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30)).resolves.toBe(
"Cancel"
);
await expect(confirm.askYesNoDialog("Timed?", { defaultOption: "No", timeout: 10 })).resolves.toBe("no");
expect(mocks.confirmAction).toHaveBeenNthCalledWith(
1,
app,
{
title: "Review",
message: "**Apply?**",
actions: ["Apply", "Cancel"],
actionLayout: "vertical",
defaultAction: "Cancel",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyConfirm).toHaveBeenCalledWith(plugin, "Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
expect.any(String),
"Timed?",
expect.any(Array),
expect.any(String),
10
);
});
it("uses Kit for untimed multi-action selection and keeps timed wide actions on the countdown dialogue", async () => {
const { confirm, app, plugin } = createConfirm();
const actions = ["Apply now", "Review later"] as const;
mocks.confirmAction.mockResolvedValueOnce("Review later");
mocks.legacyWideConfirm.mockResolvedValueOnce("Apply now");
await expect(
confirm.askSelectStringDialogue("Choose the next step", actions, {
title: "Next step",
defaultAction: "Review later",
})
).resolves.toBe("Review later");
await expect(
confirm.askSelectStringDialogue("Choose before the timer expires", actions, {
title: "Timed step",
defaultAction: "Apply now",
timeout: 15,
})
).resolves.toBe("Apply now");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
{
title: "Next step",
message: "Choose the next step",
actions,
defaultAction: "Review later",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
"Timed step",
"Choose before the timer expires",
actions,
"Apply now",
15
);
});
it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => {
const { confirm, events } = createConfirm();
let observedSignal: AbortSignal | undefined;
mocks.confirmAction.mockImplementation(
(_app, _options, lifecycle: { signal: AbortSignal }) =>
new Promise<null>((resolve) => {
observedSignal = lifecycle.signal;
lifecycle.signal.addEventListener("abort", () => resolve(null), { once: true });
})
);
const result = confirm.confirmWithMessage("Review", "Message", ["OK"], "OK");
expect(observedSignal?.aborted).toBe(false);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
await expect(result).resolves.toBe(false);
expect(observedSignal?.aborted).toBe(true);
});
it("closes an active Notice when the plug-in unload event is emitted", () => {
const { confirm, events } = createConfirm();
const popupKey = "popup-remote-size-exceeded";
const popup = {
hide: vi.fn(),
noticeEl: { isShown: vi.fn(() => true) },
};
memoObject(popupKey, popup);
(confirm as unknown as { popupKeys: Set<string> }).popupKeys.add(popupKey);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(popup.hide).toHaveBeenCalledOnce();
expect(retrieveMemoObject(popupKey)).toBe(false);
});
});