mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 19:04:46 +00:00
feat(web/vue): update version to 0.0.13 and add useTabLeader composable with tests
This commit is contained in:
222
web/vue/src/composables/browser/useTabLeader/index.test.ts
Normal file
222
web/vue/src/composables/browser/useTabLeader/index.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useTabLeader } from '.';
|
||||
|
||||
type LockGrantedCallback = (lock: unknown) => Promise<void>;
|
||||
interface MockLockRequest {
|
||||
key: string;
|
||||
callback: LockGrantedCallback;
|
||||
resolve: () => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const pendingRequests: MockLockRequest[] = [];
|
||||
let heldLocks: Set<string>;
|
||||
|
||||
function setupLocksMock() {
|
||||
heldLocks = new Set();
|
||||
|
||||
const mockLocks = {
|
||||
request: vi.fn(async (key: string, options: { signal?: AbortSignal }, callback: LockGrantedCallback) => {
|
||||
if (options.signal?.aborted) {
|
||||
throw new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
|
||||
if (heldLocks.has(key)) {
|
||||
// Queue the request — lock is held
|
||||
return new Promise<void>((resolve) => {
|
||||
const request: MockLockRequest = { key, callback, resolve, signal: options.signal };
|
||||
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
const index = pendingRequests.indexOf(request);
|
||||
if (index > -1) pendingRequests.splice(index, 1);
|
||||
resolve();
|
||||
});
|
||||
|
||||
pendingRequests.push(request);
|
||||
});
|
||||
}
|
||||
|
||||
heldLocks.add(key);
|
||||
const result = callback({} as unknown);
|
||||
|
||||
// When the callback promise resolves (lock released), grant to next waiter
|
||||
result.then(() => {
|
||||
heldLocks.delete(key);
|
||||
grantNextLock(key);
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: mockLocks,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function grantNextLock(key: string) {
|
||||
const index = pendingRequests.findIndex((r) => r.key === key);
|
||||
if (index === -1) return;
|
||||
|
||||
const [request] = pendingRequests.splice(index, 1);
|
||||
if (!request) return;
|
||||
|
||||
heldLocks.add(key);
|
||||
|
||||
const result = request.callback({} as unknown);
|
||||
result.then(() => {
|
||||
heldLocks.delete(key);
|
||||
request.resolve();
|
||||
grantNextLock(key);
|
||||
});
|
||||
}
|
||||
|
||||
const mountWithComposable = (setup: () => Record<string, any> | void) => {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup,
|
||||
template: '<div></div>',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe(useTabLeader, () => {
|
||||
let component: ReturnType<typeof mountWithComposable>;
|
||||
|
||||
beforeEach(() => {
|
||||
pendingRequests.length = 0;
|
||||
setupLocksMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
component?.unmount();
|
||||
});
|
||||
|
||||
it('acquire leadership when lock is available', async () => {
|
||||
component = mountWithComposable(() => {
|
||||
const { isLeader, isSupported } = useTabLeader('test-leader');
|
||||
return { isLeader, isSupported };
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(component.vm.isSupported).toBe(true);
|
||||
expect(component.vm.isLeader).toBe(true);
|
||||
});
|
||||
|
||||
it('not grant leadership when another tab holds the lock', async () => {
|
||||
const scope1 = effectScope();
|
||||
let leader1: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope1.run(() => {
|
||||
leader1 = useTabLeader('exclusive');
|
||||
});
|
||||
|
||||
const scope2 = effectScope();
|
||||
let leader2: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope2.run(() => {
|
||||
leader2 = useTabLeader('exclusive');
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(leader1!.isLeader.value).toBe(true);
|
||||
expect(leader2!.isLeader.value).toBe(false);
|
||||
|
||||
scope1.stop();
|
||||
scope2.stop();
|
||||
});
|
||||
|
||||
it('transfer leadership when the leader releases the lock', async () => {
|
||||
const scope1 = effectScope();
|
||||
let leader1: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope1.run(() => {
|
||||
leader1 = useTabLeader('transfer');
|
||||
});
|
||||
|
||||
const scope2 = effectScope();
|
||||
let leader2: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope2.run(() => {
|
||||
leader2 = useTabLeader('transfer');
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(leader1!.isLeader.value).toBe(true);
|
||||
expect(leader2!.isLeader.value).toBe(false);
|
||||
|
||||
// Leader 1 releases (e.g., tab closes)
|
||||
scope1.stop();
|
||||
await nextTick();
|
||||
|
||||
expect(leader1!.isLeader.value).toBe(false);
|
||||
expect(leader2!.isLeader.value).toBe(true);
|
||||
|
||||
scope2.stop();
|
||||
});
|
||||
|
||||
it('manually release and re-acquire leadership', async () => {
|
||||
const scope = effectScope();
|
||||
let leader: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope.run(() => {
|
||||
leader = useTabLeader('manual');
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBe(true);
|
||||
|
||||
leader!.release();
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBe(false);
|
||||
|
||||
leader!.acquire();
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBe(true);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('not acquire when immediate is false', async () => {
|
||||
const scope = effectScope();
|
||||
let leader: ReturnType<typeof useTabLeader>;
|
||||
|
||||
scope.run(() => {
|
||||
leader = useTabLeader('deferred', { immediate: false });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBe(false);
|
||||
expect(navigator.locks.request).not.toHaveBeenCalled();
|
||||
|
||||
leader!.acquire();
|
||||
await nextTick();
|
||||
expect(leader!.isLeader.value).toBe(true);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('fallback to isLeader always false when locks API is not supported', async () => {
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
component = mountWithComposable(() => {
|
||||
const { isLeader, isSupported } = useTabLeader('unsupported');
|
||||
return { isLeader, isSupported };
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(component.vm.isSupported).toBe(false);
|
||||
expect(component.vm.isLeader).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user