first
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { wait } from 'helpers/wait';
|
||||
|
||||
type QueueItem = {
|
||||
key: string;
|
||||
fn: () => Promise<unknown> | unknown;
|
||||
onSuccess?: () => Promise<unknown> | unknown;
|
||||
onError?: (error: unknown) => Promise<unknown> | unknown;
|
||||
onResolved?: () => Promise<unknown> | unknown;
|
||||
};
|
||||
|
||||
class Queue {
|
||||
private items: QueueItem[] = [];
|
||||
keys = new Set<string>();
|
||||
|
||||
enqueue(item: QueueItem): void {
|
||||
if (this.keys.has(item.key)) {
|
||||
throw new Error('DUPLICATE_KEY');
|
||||
}
|
||||
this.keys.add(item.key);
|
||||
this.items.push(item);
|
||||
}
|
||||
|
||||
dequeue(): QueueItem | undefined {
|
||||
return this.items.shift();
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.items.length === 0;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.items.length;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueueManager {
|
||||
private queue = new Queue();
|
||||
private isRunning = false;
|
||||
|
||||
add(item: QueueItem) {
|
||||
try {
|
||||
this.queue.enqueue(item);
|
||||
if (!this.isRunning) {
|
||||
this.isRunning = true;
|
||||
this.run();
|
||||
}
|
||||
return this.queue.size();
|
||||
} catch {
|
||||
return {
|
||||
error: 'There is already a refresh job running or in queue for this google account',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async run() {
|
||||
this.isRunning = true;
|
||||
if (this.queue.isEmpty()) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const item = this.queue.dequeue();
|
||||
if (!item) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { key, fn, onSuccess, onError, onResolved } = item;
|
||||
try {
|
||||
await fn();
|
||||
if (onSuccess) await onSuccess();
|
||||
} catch (ex) {
|
||||
if (onError) await onError(ex);
|
||||
} finally {
|
||||
if (onResolved) await onResolved();
|
||||
this.isRunning = false;
|
||||
this.queue.keys.delete(key);
|
||||
|
||||
if (!this.queue.isEmpty()) {
|
||||
await wait(1000);
|
||||
await this.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: (boolean | string | undefined | null)[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function debounce<T extends (...args: unknown[]) => void>(cb: T, delay = 1000) {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
cb(...args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect, test, beforeAll, afterAll, spyOn } from 'bun:test';
|
||||
import {
|
||||
leftpad,
|
||||
rightpad,
|
||||
formatPercent,
|
||||
formatTime,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatPercentage,
|
||||
formatDiff,
|
||||
daysAgo,
|
||||
formatByThousands,
|
||||
} from './formatters';
|
||||
|
||||
// Mock browser globals
|
||||
const originalNavigator = globalThis.navigator;
|
||||
|
||||
beforeAll(() => {
|
||||
// @ts-expect-error - mocking navigator for tests
|
||||
globalThis.navigator = {
|
||||
language: 'en-US',
|
||||
languages: ['en-US'],
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.navigator = originalNavigator;
|
||||
});
|
||||
|
||||
describe('leftpad', () => {
|
||||
test('pads number with leading zeros', () => {
|
||||
expect(leftpad(5, 3)).toBe('005');
|
||||
expect(leftpad(42, 4)).toBe('0042');
|
||||
expect(leftpad(123, 3)).toBe('123');
|
||||
});
|
||||
|
||||
test('pads string with leading zeros', () => {
|
||||
expect(leftpad('5', 3)).toBe('005');
|
||||
expect(leftpad('ab', 4)).toBe('00ab');
|
||||
});
|
||||
|
||||
test('handles value already at or exceeding size', () => {
|
||||
expect(leftpad(12345, 3)).toBe('12345');
|
||||
expect(leftpad('abc', 3)).toBe('abc');
|
||||
});
|
||||
|
||||
test('handles default value of 0', () => {
|
||||
expect(leftpad(undefined, 3)).toBe('000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rightpad', () => {
|
||||
test('pads number with trailing zeros', () => {
|
||||
expect(rightpad(5, 3)).toBe('500');
|
||||
expect(rightpad(42, 4)).toBe('4200');
|
||||
});
|
||||
|
||||
test('pads string with trailing zeros', () => {
|
||||
expect(rightpad('5', 3)).toBe('500');
|
||||
expect(rightpad('ab', 4)).toBe('ab00');
|
||||
});
|
||||
|
||||
test('handles value already at or exceeding size', () => {
|
||||
expect(rightpad(12345, 3)).toBe('12345');
|
||||
});
|
||||
|
||||
test('handles default value of 0', () => {
|
||||
expect(rightpad(undefined, 3)).toBe('000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercent', () => {
|
||||
test('formats number with 2 decimal places', () => {
|
||||
expect(formatPercent(5.5)).toBe('05.50');
|
||||
expect(formatPercent(12.34)).toBe('12.34');
|
||||
expect(formatPercent(99.9)).toBe('99.90');
|
||||
});
|
||||
|
||||
test('formats string numbers', () => {
|
||||
expect(formatPercent('5.5')).toBe('05.50');
|
||||
expect(formatPercent('12.34')).toBe('12.34');
|
||||
});
|
||||
|
||||
test('handles whole numbers', () => {
|
||||
expect(formatPercent(5)).toBe('05.00');
|
||||
expect(formatPercent('12')).toBe('12.00');
|
||||
});
|
||||
|
||||
test('handles zero', () => {
|
||||
expect(formatPercent(0)).toBe('00.00');
|
||||
expect(formatPercent('0')).toBe('00.00');
|
||||
});
|
||||
|
||||
test('returns empty string for null/undefined', () => {
|
||||
expect(formatPercent(null as unknown as number)).toBe('');
|
||||
expect(formatPercent(undefined as unknown as number)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercentage', () => {
|
||||
test('formats number as percentage string', () => {
|
||||
expect(formatPercentage(5.5)).toBe('5.50%');
|
||||
expect(formatPercentage(12.345)).toBe('12.35%');
|
||||
expect(formatPercentage(0.1)).toBe('0.10%');
|
||||
});
|
||||
|
||||
test('formats string numbers', () => {
|
||||
expect(formatPercentage('5.5')).toBe('5.50%');
|
||||
expect(formatPercentage('99')).toBe('99.00%');
|
||||
});
|
||||
|
||||
test('returns default for falsy values', () => {
|
||||
expect(formatPercentage(0)).toBe('00.00%');
|
||||
expect(formatPercentage('')).toBe('00.00%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiff', () => {
|
||||
test('formats positive difference with plus sign', () => {
|
||||
expect(formatDiff(5)).toBe('+5');
|
||||
expect(formatDiff(10.7)).toBe('+11');
|
||||
});
|
||||
|
||||
test('formats negative difference without extra sign', () => {
|
||||
expect(formatDiff(-5)).toBe('-5');
|
||||
expect(formatDiff(-10.7)).toBe('-11');
|
||||
});
|
||||
|
||||
test('adds percent suffix when isPercent is true', () => {
|
||||
expect(formatDiff(5, true)).toBe('+5%');
|
||||
expect(formatDiff(-3, true)).toBe('-3%');
|
||||
});
|
||||
|
||||
test('shows decimals when decimals is true', () => {
|
||||
expect(formatDiff(5.55, false, true)).toBe('+5.55');
|
||||
expect(formatDiff(-3.14, true, true)).toBe('-3.14%');
|
||||
});
|
||||
|
||||
test('returns empty string for falsy values', () => {
|
||||
expect(formatDiff(0)).toBe('');
|
||||
expect(formatDiff('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('daysAgo', () => {
|
||||
const oneDay = 1000 * 60 * 60 * 24;
|
||||
|
||||
test('calculates days between two timestamps', () => {
|
||||
const now = Date.now();
|
||||
expect(daysAgo(now, now - oneDay)).toBe(1);
|
||||
expect(daysAgo(now, now - oneDay * 5)).toBe(5);
|
||||
});
|
||||
|
||||
test('order of arguments does not matter (absolute difference)', () => {
|
||||
const now = Date.now();
|
||||
const past = now - oneDay * 3;
|
||||
expect(daysAgo(now, past)).toBe(3);
|
||||
expect(daysAgo(past, now)).toBe(3);
|
||||
});
|
||||
|
||||
test('same timestamp returns 0', () => {
|
||||
const now = Date.now();
|
||||
expect(daysAgo(now, now)).toBe(0);
|
||||
});
|
||||
|
||||
test('partial days are rounded up', () => {
|
||||
const now = Date.now();
|
||||
const halfDayAgo = now - oneDay * 0.5;
|
||||
expect(daysAgo(now, halfDayAgo)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatByThousands', () => {
|
||||
test('formats millions with M suffix', () => {
|
||||
const result = formatByThousands(1500000);
|
||||
expect(result).toContain('M');
|
||||
expect(result).toContain('1');
|
||||
});
|
||||
|
||||
test('formats thousands with K suffix', () => {
|
||||
const result = formatByThousands(5000);
|
||||
expect(result).toContain('K');
|
||||
expect(result).toContain('5');
|
||||
});
|
||||
|
||||
test('formats small numbers without suffix', () => {
|
||||
const result = formatByThousands(500);
|
||||
expect(result).not.toContain('K');
|
||||
expect(result).not.toContain('M');
|
||||
});
|
||||
|
||||
test('handles zero', () => {
|
||||
const result = formatByThousands(0);
|
||||
expect(result).toContain('0');
|
||||
});
|
||||
|
||||
test('handles exactly 1000', () => {
|
||||
const result = formatByThousands(1000);
|
||||
expect(result).toContain('K');
|
||||
});
|
||||
|
||||
test('handles exactly 1000000', () => {
|
||||
const result = formatByThousands(1000000);
|
||||
expect(result).toContain('M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
test('formats timestamp to time string', () => {
|
||||
const mockToLocaleTimeString = spyOn(Date.prototype, 'toLocaleTimeString').mockReturnValue('10:30:00 AM');
|
||||
const result = formatTime(1706180400000);
|
||||
expect(result).toBe('10:30:00 AM');
|
||||
mockToLocaleTimeString.mockRestore();
|
||||
});
|
||||
|
||||
test('returns empty string for falsy values', () => {
|
||||
expect(formatTime(0)).toBe('');
|
||||
expect(formatTime('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
test('formats timestamp to date string', () => {
|
||||
const mockToLocaleDateString = spyOn(Date.prototype, 'toLocaleDateString').mockReturnValue('1/25/2024');
|
||||
const result = formatDate(1706180400000);
|
||||
expect(result).toBe('1/25/2024');
|
||||
mockToLocaleDateString.mockRestore();
|
||||
});
|
||||
|
||||
test('returns empty string for falsy values', () => {
|
||||
expect(formatDate(0)).toBe('');
|
||||
expect(formatDate('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
test('formats timestamp to date and time string', () => {
|
||||
const mockToLocaleDateString = spyOn(Date.prototype, 'toLocaleDateString').mockReturnValue('1/25/2024');
|
||||
const mockToLocaleTimeString = spyOn(Date.prototype, 'toLocaleTimeString').mockReturnValue('10:30:00 AM');
|
||||
const result = formatDateTime(1706180400000);
|
||||
expect(result).toBe('1/25/2024 10:30:00 AM');
|
||||
mockToLocaleDateString.mockRestore();
|
||||
mockToLocaleTimeString.mockRestore();
|
||||
});
|
||||
|
||||
test('returns empty string for falsy values', () => {
|
||||
expect(formatDateTime(0)).toBe('');
|
||||
expect(formatDateTime('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCurrency', () => {
|
||||
const OriginalNumberFormat = Intl.NumberFormat;
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.Intl.NumberFormat = OriginalNumberFormat;
|
||||
});
|
||||
|
||||
test('formats number as currency', () => {
|
||||
// @ts-expect-error - mocking Intl.NumberFormat
|
||||
globalThis.Intl.NumberFormat = class {
|
||||
format = () => '$1,234.56';
|
||||
};
|
||||
const result = formatCurrency(1234.56, 'USD');
|
||||
expect(result).toBe('$1,234.56');
|
||||
});
|
||||
|
||||
test('handles string input', () => {
|
||||
// @ts-expect-error - mocking Intl.NumberFormat
|
||||
globalThis.Intl.NumberFormat = class {
|
||||
format = () => '$99.00';
|
||||
};
|
||||
const result = formatCurrency('99', 'USD');
|
||||
expect(result).toBe('$99.00');
|
||||
});
|
||||
|
||||
test('handles zero and NaN', () => {
|
||||
// @ts-expect-error - mocking Intl.NumberFormat
|
||||
globalThis.Intl.NumberFormat = class {
|
||||
format = () => '$0.00';
|
||||
};
|
||||
expect(formatCurrency(0, 'USD')).toBe('$0.00');
|
||||
expect(formatCurrency(NaN, 'USD')).toBe('$0.00');
|
||||
});
|
||||
|
||||
test('respects decimals parameter', () => {
|
||||
// @ts-expect-error - mocking Intl.NumberFormat
|
||||
globalThis.Intl.NumberFormat = class {
|
||||
format = () => '€10.000';
|
||||
};
|
||||
const result = formatCurrency(10, 'EUR', 3);
|
||||
expect(result).toBe('€10.000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNumber', () => {
|
||||
const OriginalNumberFormat = Intl.NumberFormat;
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.Intl.NumberFormat = OriginalNumberFormat;
|
||||
});
|
||||
|
||||
test('formats number with locale formatting', () => {
|
||||
// @ts-expect-error - mocking Intl.NumberFormat
|
||||
globalThis.Intl.NumberFormat = class {
|
||||
format = () => '1,234,567';
|
||||
};
|
||||
const result = formatNumber(1234567);
|
||||
expect(result).toBe('1,234,567');
|
||||
});
|
||||
|
||||
test('handles string input', () => {
|
||||
// @ts-expect-error - mocking Intl.NumberFormat
|
||||
globalThis.Intl.NumberFormat = class {
|
||||
format = () => '999';
|
||||
};
|
||||
const result = formatNumber('999');
|
||||
expect(result).toBe('999');
|
||||
});
|
||||
|
||||
test('handles zero and NaN', () => {
|
||||
// @ts-expect-error - mocking Intl.NumberFormat
|
||||
globalThis.Intl.NumberFormat = class {
|
||||
format = () => '0';
|
||||
};
|
||||
expect(formatNumber(0)).toBe('0');
|
||||
expect(formatNumber(NaN)).toBe('0');
|
||||
});
|
||||
});
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
export function leftpad(value: number | string = 0, size: number) {
|
||||
let s = value + '';
|
||||
while (s.length < size) s = '0' + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
export function rightpad(value: number | string = 0, size: number) {
|
||||
let s = value + '';
|
||||
while (s.length < size) s = s + '0';
|
||||
return s;
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | string) {
|
||||
if (typeof value === 'undefined' || value === null) return '';
|
||||
const [left, right] = value.toString().split('.');
|
||||
return `${leftpad(left || 0, 2)}.${rightpad(right || 0, 2)}`;
|
||||
}
|
||||
|
||||
export const formatTime = (value: number | string) => {
|
||||
return !!value ? new Date(value).toLocaleTimeString() : '';
|
||||
};
|
||||
|
||||
export const formatDate = (value: number | string) => {
|
||||
return !!value ? new Date(value).toLocaleDateString() : '';
|
||||
};
|
||||
|
||||
export const formatDateTime = (value: number | string) => {
|
||||
return !!value ? `${formatDate(value)} ${formatTime(value)}` : '';
|
||||
};
|
||||
|
||||
export function formatCurrency(value: number | string, currency: string, decimals: number = 2) {
|
||||
const locale = navigator.language || navigator.languages[0];
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
});
|
||||
let valueToFormat = (+value).toFixed(decimals);
|
||||
return formatter.format(+valueToFormat || 0);
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | string) {
|
||||
const locale = navigator.language || navigator.languages[0];
|
||||
const formatter = new Intl.NumberFormat(locale);
|
||||
return formatter.format(+value || 0);
|
||||
}
|
||||
|
||||
export const formatPercentage = (value: string | number) => (value ? `${(+value).toFixed(2)}%` : '00.00%');
|
||||
|
||||
export const formatDiff = (value: string | number, isPercent = false, decimals = false) =>
|
||||
value ? `${+value > 0 ? '+' : ''}${(+value).toFixed(decimals ? 2 : 0)}${isPercent ? '%' : ''}` : '';
|
||||
|
||||
export function daysAgo(first: number, last: number) {
|
||||
return Math.ceil(Math.abs(first - last) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export function formatByThousands(value: number) {
|
||||
const locale = navigator.language || navigator.languages[0];
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
if (value >= 1000000) {
|
||||
return `${formatter.format(value / 1000000)}M`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${formatter.format(value / 1000)}K`;
|
||||
}
|
||||
return formatter.format(value);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
let isListening = false;
|
||||
|
||||
export function listenForUrlChange(cb: (url: string) => void) {
|
||||
let currentUrl = window.location.href;
|
||||
|
||||
function listen() {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href;
|
||||
cb(currentUrl);
|
||||
}
|
||||
requestAnimationFrame(listen);
|
||||
}
|
||||
|
||||
if (!isListening) {
|
||||
isListening = true;
|
||||
listen();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "helpers",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Frontend
|
||||
|
||||
type PasskeyUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
|
||||
export function arrayBufferToBase64(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('');
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
export function getCredentialPayload(challenge: number[], user: PasskeyUser) {
|
||||
return {
|
||||
challenge: new Uint8Array(challenge),
|
||||
rp: {
|
||||
name: 'Pertento AI',
|
||||
},
|
||||
user: {
|
||||
id: Uint8Array.from(atob(btoa(user.id)), (c) => c.charCodeAt(0)),
|
||||
name: user.email,
|
||||
displayName: `${user.firstName} ${user.lastName}`,
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{ type: 'public-key' as const, alg: -7 },
|
||||
{ type: 'public-key' as const, alg: -257 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function getSigninPayload(challenge: number[], credentialIds: string[]) {
|
||||
return {
|
||||
challenge: new Uint8Array(challenge),
|
||||
allowCredentials: credentialIds.map((id) => {
|
||||
const urlSafeId = id.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const decodedId = atob(urlSafeId);
|
||||
return {
|
||||
type: 'public-key' as const,
|
||||
id: Uint8Array.from(decodedId, (c) => c.charCodeAt(0)),
|
||||
};
|
||||
}),
|
||||
userVerification: 'preferred' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// Backend
|
||||
|
||||
export function createChallenge() {
|
||||
const challenge = crypto.randomUUID();
|
||||
const hex = challenge.replace(/-/g, '');
|
||||
const array: number[] = new Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
array[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
export const hexToString = (array: number[]) => array.map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
@@ -0,0 +1,22 @@
|
||||
export function stringifyFunction(fn: (() => void) | null | undefined, args?: Record<string, unknown>) {
|
||||
if (!fn) return '';
|
||||
const functionAsString = fn.toString();
|
||||
const bodyStart = functionAsString.indexOf('{') + 1;
|
||||
const bodyEnd = functionAsString.lastIndexOf('}');
|
||||
let functionBody = functionAsString.substring(bodyStart, bodyEnd).trim();
|
||||
|
||||
if (args) {
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
const regex = new RegExp(`\\b${key}\\b`, 'g');
|
||||
functionBody = functionBody.replace(regex, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
functionBody = functionBody
|
||||
.replace(/\bev\./g, 'event.')
|
||||
.replace(/\}\s\= ev/g, '} = event')
|
||||
.replace(/(\r\n|\n|\r)/gm, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
|
||||
return `(function(){${functionBody}})();`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function throttle<T extends (...args: unknown[]) => void>(func: T, interval: number) {
|
||||
let isRunning = false;
|
||||
|
||||
return function (this: unknown, ...args: Parameters<T>) {
|
||||
if (!isRunning) {
|
||||
isRunning = true;
|
||||
func.apply(this, args);
|
||||
setTimeout(() => {
|
||||
isRunning = false;
|
||||
}, interval);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { checkUrlTargeting, testRegex } from './url-match';
|
||||
|
||||
describe('testRegex', () => {
|
||||
test('returns true for matching regex', () => {
|
||||
expect(testRegex('hello', 'hello world')).toBe(true);
|
||||
expect(testRegex('^https://', 'https://example.com')).toBe(true);
|
||||
expect(testRegex('/products/\\d+', '/products/123')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for non-matching regex', () => {
|
||||
expect(testRegex('^https://', 'http://example.com')).toBe(false);
|
||||
expect(testRegex('/products/\\d+$', '/products/abc')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for invalid regex', () => {
|
||||
expect(testRegex('[invalid', 'test')).toBe(false);
|
||||
expect(testRegex('(unclosed', 'test')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkUrlTargeting', () => {
|
||||
describe('empty targeting', () => {
|
||||
test('returns true for empty targeting array', () => {
|
||||
expect(checkUrlTargeting([], 'https://example.com')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals condition', () => {
|
||||
test('matches exact URL', () => {
|
||||
const targeting = [{ url: 'https://example.com/page', condition: 'equals' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match different URL', () => {
|
||||
const targeting = [{ url: 'https://example.com/page', condition: 'equals' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/other')).toBe(false);
|
||||
});
|
||||
|
||||
test('ignores trailing slashes', () => {
|
||||
const targeting = [{ url: 'https://example.com/page/', condition: 'equals' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('not equals condition', () => {
|
||||
test('matches when URL is different', () => {
|
||||
const targeting = [{ url: 'https://example.com/page', condition: 'not equals' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/other')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match when URL is same', () => {
|
||||
const targeting = [{ url: 'https://example.com/page', condition: 'not equals' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contains condition', () => {
|
||||
test('matches when URL contains substring', () => {
|
||||
const targeting = [{ url: 'example', condition: 'contains' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(true);
|
||||
});
|
||||
|
||||
test('matches partial path', () => {
|
||||
const targeting = [{ url: '/products/', condition: 'contains' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/products/123')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match when substring not present', () => {
|
||||
const targeting = [{ url: 'foobar', condition: 'contains' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('does not contain condition', () => {
|
||||
test('matches when URL does not contain substring', () => {
|
||||
const targeting = [{ url: 'admin', condition: 'does not contain' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match when URL contains substring', () => {
|
||||
const targeting = [{ url: 'example', condition: 'does not contain' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('starts with condition', () => {
|
||||
test('matches URL starting with prefix', () => {
|
||||
const targeting = [{ url: 'https://example.com', condition: 'starts with' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match URL with different start', () => {
|
||||
const targeting = [{ url: 'https://other.com', condition: 'starts with' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ends with condition', () => {
|
||||
test('matches URL ending with suffix', () => {
|
||||
const targeting = [{ url: '/checkout', condition: 'ends with' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/checkout')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match URL with different ending', () => {
|
||||
const targeting = [{ url: '/checkout', condition: 'ends with' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/cart')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matches regex condition', () => {
|
||||
test('matches valid regex pattern', () => {
|
||||
const targeting = [{ url: '/products/\\d+', condition: 'matches regex' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/products/123')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match non-matching pattern', () => {
|
||||
const targeting = [{ url: '/products/\\d+$', condition: 'matches regex' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/products/abc')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles invalid regex gracefully', () => {
|
||||
const targeting = [{ url: '[invalid', condition: 'matches regex' as const }];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/page')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple targeting rules', () => {
|
||||
test('returns true if any rule matches (OR logic)', () => {
|
||||
const targeting = [
|
||||
{ url: '/admin', condition: 'contains' as const },
|
||||
{ url: '/dashboard', condition: 'contains' as const },
|
||||
];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/dashboard')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if no rules match', () => {
|
||||
const targeting = [
|
||||
{ url: '/admin', condition: 'contains' as const },
|
||||
{ url: '/dashboard', condition: 'contains' as const },
|
||||
];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com/home')).toBe(false);
|
||||
});
|
||||
|
||||
test('first matching rule short-circuits', () => {
|
||||
const targeting = [
|
||||
{ url: 'example', condition: 'contains' as const },
|
||||
{ url: '[invalid', condition: 'matches regex' as const }, // invalid, but won't be checked
|
||||
];
|
||||
expect(checkUrlTargeting(targeting, 'https://example.com')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { RuntimeUrlTargeting } from 'types/runtime';
|
||||
|
||||
export const checkUrlTargeting = (urlTargeting: RuntimeUrlTargeting[], url?: string): boolean => {
|
||||
const pageUrl = (url || window.location.href).replace(/\/$/, '');
|
||||
|
||||
if (urlTargeting.length === 0) return true;
|
||||
|
||||
for (const { url: targetUrlRaw, condition } of urlTargeting) {
|
||||
const targetUrl = targetUrlRaw.replace(/\/$/, '');
|
||||
|
||||
switch (condition) {
|
||||
case 'does not contain':
|
||||
if (!pageUrl.includes(targetUrl)) return true;
|
||||
break;
|
||||
case 'matches regex':
|
||||
if (testRegex(targetUrl, pageUrl)) return true;
|
||||
break;
|
||||
case 'starts with':
|
||||
if (pageUrl.startsWith(targetUrl)) return true; // ✅ fixed
|
||||
break;
|
||||
case 'contains':
|
||||
if (pageUrl.includes(targetUrl)) return true;
|
||||
break;
|
||||
case 'ends with':
|
||||
if (pageUrl.endsWith(targetUrl)) return true;
|
||||
break;
|
||||
case 'not equals':
|
||||
if (pageUrl !== targetUrl) return true;
|
||||
break;
|
||||
case 'equals':
|
||||
if (pageUrl === targetUrl) return true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false; // none of them matched
|
||||
};
|
||||
|
||||
export const testRegex = (regexStr: string, url: string): boolean => {
|
||||
try {
|
||||
return new RegExp(regexStr).test(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const wait = (ms = 1000) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
Reference in New Issue
Block a user