Files
obsidian-livesync/src/serviceFeatures/syncGuard/SyncGuardOverlay.unit.spec.ts
T

216 lines
8.8 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { SyncGuardOverlay } from "./SyncGuardOverlay";
class FakeDocument {
activeElement: FakeElement | undefined;
listeners = new Map<string, (event: KeyboardEvent) => void>();
addEventListener(type: string, listener: (event: KeyboardEvent) => void): void {
this.listeners.set(type, listener);
}
removeEventListener(type: string): void {
this.listeners.delete(type);
}
}
class FakeElement {
readonly children: FakeElement[] = [];
readonly handlers = new Map<string, () => void>();
readonly attributes = new Map<string, string>();
className = "";
textContent = "";
type = "";
isConnected = true;
parent: FakeElement | undefined;
constructor(
readonly ownerDocument: FakeDocument,
readonly tagName: string
) {}
createEl(tagName: string, options?: { cls?: string }): FakeElement {
return this.append(new FakeElement(this.ownerDocument, tagName), options);
}
createDiv(options?: { cls?: string }): FakeElement {
return this.createEl("div", options);
}
private append(child: FakeElement, options?: { cls?: string }): FakeElement {
child.className = options?.cls ?? "";
child.parent = this;
this.children.push(child);
return child;
}
setAttribute(name: string, value: string): void {
this.attributes.set(name, value);
}
addEventListener(name: string, callback: () => void): void {
this.handlers.set(name, callback);
}
contains(target: FakeElement): boolean {
return target === this || this.children.some((child) => child.contains(target));
}
closest(selector: string): FakeElement | null {
if (selector !== ".modal-container") return null;
return this.className === "modal-container" ? this : (this.parent?.closest(selector) ?? null);
}
focus(): void {
this.ownerDocument.activeElement = this;
}
remove(): void {
if (!this.parent) return;
const index = this.parent.children.indexOf(this);
if (index >= 0) this.parent.children.splice(index, 1);
this.isConnected = false;
}
}
function setup() {
vi.stubGlobal("HTMLElement", FakeElement);
vi.stubGlobal("Element", FakeElement);
vi.stubGlobal("Node", FakeElement);
const doc = new FakeDocument();
const workspace = new FakeElement(doc, "workspace");
const editor = workspace.createEl("textarea");
editor.focus();
const bypass = vi.fn();
const overlay = new SyncGuardOverlay(workspace as unknown as HTMLElement, bypass);
return { overlay, doc, workspace, editor, bypass };
}
describe("SyncGuardOverlay", () => {
afterEach(() => vi.unstubAllGlobals());
it("covers only the workspace, exposes a keyboard-operable bypass, and restores focus", () => {
const { overlay, doc, workspace, editor, bypass } = setup();
overlay.update({ phase: "observing", receivedFiles: 0 });
expect(workspace.children).toHaveLength(1);
overlay.update({ phase: "transferring", receivedFiles: 20 });
const root = workspace.children[1];
const panel = root.children[0];
const button = panel.children[3];
expect(root.attributes.get("role")).toBe("region");
expect(button.tagName).toBe("button");
expect(button.type).toBe("button");
expect(doc.activeElement).toBe(button);
expect(panel.children[2].textContent).toContain("20 changed files");
button.handlers.get("click")?.();
expect(bypass).toHaveBeenCalledOnce();
overlay.update({ phase: "idle", receivedFiles: 20 });
expect(workspace.children).toHaveLength(1);
expect(doc.activeElement).toBe(editor);
expect(doc.listeners.size).toBe(0);
});
it("prevents editor keyboard shortcuts while allowing navigation inside the guard", () => {
const { overlay, doc, editor, workspace } = setup();
overlay.update({ phase: "applying", receivedFiles: 25 });
const button = workspace.children[1].children[0].children[3];
const block = doc.listeners.get("keydown");
const preventDefault = vi.fn();
const stopImmediatePropagation = vi.fn();
block?.({ target: editor, key: "x", preventDefault, stopImmediatePropagation } as unknown as KeyboardEvent);
expect(preventDefault).toHaveBeenCalledOnce();
expect(stopImmediatePropagation).toHaveBeenCalledOnce();
block?.({ target: button, key: "Enter", preventDefault, stopImmediatePropagation } as unknown as KeyboardEvent);
expect(preventDefault).toHaveBeenCalledOnce();
overlay.dispose();
expect(doc.listeners.size).toBe(0);
});
it("presents a safe offline bypass when the catch-up is interrupted", () => {
const { overlay, workspace } = setup();
overlay.update({ phase: "interrupted", receivedFiles: 30 });
const panel = workspace.children[1].children[0];
expect(panel.children[1].textContent).toContain("some notes may be out of date");
overlay.dispose();
});
it("does not steal focus from an existing Obsidian dialogue", () => {
const { overlay, doc, workspace } = setup();
const modal = new FakeElement(doc, "div");
modal.className = "modal-container";
const modalInput = modal.createEl("input");
modalInput.focus();
overlay.update({ phase: "transferring", receivedFiles: 20 });
expect(doc.activeElement).toBe(modalInput);
const preventDefault = vi.fn();
const stopImmediatePropagation = vi.fn();
doc.listeners.get("keydown")?.({
target: modalInput, key: "Escape", preventDefault, stopImmediatePropagation,
} as unknown as KeyboardEvent);
expect(preventDefault).not.toHaveBeenCalled();
expect(stopImmediatePropagation).not.toHaveBeenCalled();
overlay.dispose();
expect(workspace.children).toHaveLength(1);
expect(doc.activeElement).toBe(modalInput);
});
it("does not restore editor focus if a dialogue opened while the guard was active", () => {
const { overlay, doc } = setup();
overlay.update({ phase: "transferring", receivedFiles: 20 });
const modal = new FakeElement(doc, "div");
modal.className = "modal-container";
const input = modal.createEl("input");
input.focus();
overlay.update({ phase: "idle", receivedFiles: 20 });
expect(doc.activeElement).toBe(input);
});
it("keeps Tab and Shift+Tab on the bypass button rather than an editor behind the guard", () => {
const { overlay, doc, workspace, editor } = setup();
overlay.update({ phase: "transferring", receivedFiles: 20 });
const button = workspace.children[1].children[0].children[3];
for (const shiftKey of [false, true]) {
const preventDefault = vi.fn();
const stopImmediatePropagation = vi.fn();
doc.listeners.get("keydown")?.({
target: button, key: "Tab", shiftKey, preventDefault, stopImmediatePropagation,
} as unknown as KeyboardEvent);
expect(preventDefault).toHaveBeenCalledOnce();
expect(stopImmediatePropagation).toHaveBeenCalledOnce();
expect(doc.activeElement).toBe(button);
}
const preventDefault = vi.fn();
const stopImmediatePropagation = vi.fn();
doc.listeners.get("keydown")?.({
target: editor, key: "Tab", preventDefault, stopImmediatePropagation,
} as unknown as KeyboardEvent);
expect(preventDefault).toHaveBeenCalledOnce();
expect(doc.activeElement).toBe(button);
overlay.dispose();
});
it("blocks mobile beforeinput and paste into hidden editors but permits input in Obsidian dialogues", () => {
const { overlay, doc, editor } = setup();
overlay.update({ phase: "transferring", receivedFiles: 30 });
const modal = new FakeElement(doc, "div");
modal.className = "modal-container";
const input = modal.createEl("input");
for (const eventType of ["beforeinput", "paste", "drop"]) {
const preventDefault = vi.fn();
const stopImmediatePropagation = vi.fn();
doc.listeners.get(eventType)?.({
target: editor, preventDefault, stopImmediatePropagation,
} as unknown as KeyboardEvent);
expect(preventDefault).toHaveBeenCalledOnce();
expect(stopImmediatePropagation).toHaveBeenCalledOnce();
doc.listeners.get(eventType)?.({
target: input, preventDefault, stopImmediatePropagation,
} as unknown as KeyboardEvent);
expect(preventDefault).toHaveBeenCalledOnce();
}
overlay.dispose();
expect(doc.listeners.size).toBe(0);
});
});