Files
tools/vue/toolkit/src/composables/browser/useCloseWatcher/index.test.ts
T
robonen c7644ade69 fix(vue): eslint/tsconfig migration + resolve type errors
@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig;
fix composable + test type errors (writable computed returns, null guards,
overload-compatible signatures, typed test helpers) — all type-level.
2026-06-07 16:29:39 +07:00

349 lines
10 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import { useCloseWatcher } from '.';
/**
* Minimal fake of the native `CloseWatcher`: tracks instances so tests can drive
* close/destroy and assert recreation behaviour.
*/
function createCloseWatcherStub() {
const instances: FakeCloseWatcher[] = [];
class FakeCloseWatcher {
listeners = new Map<string, Set<(event: Event) => void>>();
destroyed = false;
requestCloseCalls = 0;
closeCalls = 0;
constructor() {
instances.push(this);
}
addEventListener(type: string, listener: (event: Event) => void) {
if (!this.listeners.has(type))
this.listeners.set(type, new Set());
this.listeners.get(type)!.add(listener);
}
removeEventListener(type: string, listener: (event: Event) => void) {
this.listeners.get(type)?.delete(listener);
}
requestClose() {
this.requestCloseCalls++;
this.fireClose();
}
close() {
this.closeCalls++;
this.fireClose();
}
destroy() {
this.destroyed = true;
}
private fireClose() {
const event = new Event('close');
this.listeners.get('close')?.forEach(fn => fn(event));
}
oncancel: ((event: Event) => void) | null = null;
onclose: ((event: Event) => void) | null = null;
}
// A window-like object that exposes CloseWatcher and basic event listening
const eventTarget = new EventTarget();
const win = {
CloseWatcher: FakeCloseWatcher,
addEventListener: eventTarget.addEventListener.bind(eventTarget),
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
} as unknown as Window;
return { win, instances };
}
/** A window-like object WITHOUT CloseWatcher (fallback path). */
function createFallbackWindow() {
const eventTarget = new EventTarget();
const win = {
addEventListener: eventTarget.addEventListener.bind(eventTarget),
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
} as unknown as Window;
const dispatchKey = (key: string) =>
win.dispatchEvent(new KeyboardEvent('keydown', { key }));
return { win, dispatchKey };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useCloseWatcher, () => {
it('reports support when CloseWatcher exists on window', () => {
const { win } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
expect(cw!.isSupported.value).toBeTruthy();
scope.stop();
});
it('reports unsupported when CloseWatcher is absent', () => {
const { win } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
expect(cw!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is a safe no-op when there is no window (SSR)', () => {
// Force the SSR branch with an explicit falsy (non-undefined) window so the
// default-parameter fallback to `defaultWindow` does not kick in: only
// `undefined` triggers a parameter default, `null` survives the destructure.
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: null as unknown as Window });
});
expect(cw!.isSupported.value).toBeFalsy();
const handler = vi.fn();
const stop = cw!.onClose(handler);
expect(() => cw!.close()).not.toThrow();
expect(handler).not.toHaveBeenCalled();
expect(() => stop()).not.toThrow();
expect(() => cw!.destroy()).not.toThrow();
scope.stop();
});
describe('native CloseWatcher path', () => {
it('fires registered handler when close() is requested', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
expect(instances).toHaveLength(1);
cw!.close();
expect(instances[0]!.requestCloseCalls).toBe(1);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]![0]).toBeInstanceOf(Event);
scope.stop();
});
it('fires handler when the native close event occurs (Esc / back)', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
// Simulate the platform firing the close event (e.g. Esc / Android back)
instances[0]!.close();
expect(handler).toHaveBeenCalledTimes(1);
scope.stop();
});
it('recreates the watcher after a close so it keeps working', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
expect(instances).toHaveLength(1);
cw!.close();
// a fresh watcher is created after the close fired
expect(instances).toHaveLength(2);
cw!.close();
expect(handler).toHaveBeenCalledTimes(2);
scope.stop();
});
it('fires all registered handlers with a single watcher', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const a = vi.fn();
const b = vi.fn();
cw!.onClose(a);
cw!.onClose(b);
// both handlers share one native watcher
expect(instances).toHaveLength(1);
cw!.close();
expect(a).toHaveBeenCalledTimes(1);
expect(b).toHaveBeenCalledTimes(1);
scope.stop();
});
it('stop handle removes only its own handler', () => {
const { win } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const a = vi.fn();
const b = vi.fn();
const stopA = cw!.onClose(a);
cw!.onClose(b);
stopA();
cw!.close();
expect(a).not.toHaveBeenCalled();
expect(b).toHaveBeenCalledTimes(1);
scope.stop();
});
it('destroy() tears down the watcher and clears handlers', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
cw!.destroy();
expect(instances[0]!.destroyed).toBeTruthy();
cw!.close();
expect(handler).not.toHaveBeenCalled();
scope.stop();
});
it('survives a handler calling destroy() during dispatch', () => {
const { win } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const other = vi.fn();
cw!.onClose(() => cw!.destroy());
cw!.onClose(other);
// dispatch must not throw even though destroy() clears the set mid-loop
expect(() => cw!.close()).not.toThrow();
// the snapshot means the second handler still runs for this dispatch
expect(other).toHaveBeenCalledTimes(1);
scope.stop();
});
});
describe('fallback (keydown) path', () => {
it('fires handler on Escape keydown', () => {
const { win, dispatchKey } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
dispatchKey('Escape');
expect(handler).toHaveBeenCalledTimes(1);
scope.stop();
});
it('ignores non-Escape keys', () => {
const { win, dispatchKey } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
dispatchKey('Enter');
expect(handler).not.toHaveBeenCalled();
scope.stop();
});
it('close() synthesizes a close event in the fallback path', () => {
const { win } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
cw!.close();
expect(handler).toHaveBeenCalledTimes(1);
scope.stop();
});
it('destroy() removes the keydown listener', () => {
const { win, dispatchKey } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
cw!.destroy();
dispatchKey('Escape');
expect(handler).not.toHaveBeenCalled();
scope.stop();
});
});
it('disposes when the effect scope stops', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
cw!.onClose(vi.fn());
expect(instances[0]!.destroyed).toBeFalsy();
scope.stop();
expect(instances[0]!.destroyed).toBeTruthy();
});
});