1
0
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:
2026-02-15 05:29:08 +07:00
parent 624e12ed96
commit c48de9a3d1
8 changed files with 940 additions and 23 deletions

View 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);
});
});