diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fade885..1881dea 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,13 +22,15 @@ jobs: CI_JOB_NUMBER: 2 steps: - uses: actions/checkout@v1 + with: + fetch-depth: 0 - name: Use Node.js from .nvmrc uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - run: corepack enable - run: yarn install - - run: yarn workspace @hawk.so/javascript test + - run: yarn test:modified origin/${{ github.event.pull_request.base.ref }} build: runs-on: ubuntu-latest diff --git a/package.json b/package.json index 01c1ec1..d59466e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "dev": "yarn workspace @hawk.so/javascript dev", "build:all": "yarn workspaces foreach -Apt run build", "build:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run build", + "test:all": "yarn workspaces foreach -Apt run test", + "test:modified": "yarn workspaces foreach --since=\"$@\" -Rpt run test", "stats": "yarn workspace @hawk.so/javascript stats", "lint": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js --fix", "lint-test": "eslint -c ./.eslintrc.cjs packages/*/src --ext .ts,.js" diff --git a/packages/core/package.json b/packages/core/package.json index e06be1d..6797c92 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,7 +17,10 @@ } }, "scripts": { - "build": "vite build" + "build": "vite build", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "lint": "eslint --fix \"src/**/*.{js,ts}\"" }, "repository": { "type": "git", @@ -33,8 +36,13 @@ "url": "https://github.com/codex-team/hawk.javascript/issues" }, "homepage": "https://github.com/codex-team/hawk.javascript#readme", + "dependencies": { + "@hawk.so/types": "0.5.8" + }, "devDependencies": { + "@vitest/coverage-v8": "^4.0.18", "vite": "^7.3.1", - "vite-plugin-dts": "^4.2.4" + "vite-plugin-dts": "^4.2.4", + "vitest": "^4.0.18" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6a701eb..1a05cf1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,2 @@ export type { HawkStorage } from './storages/hawk-storage'; +export { HawkUserManager } from './users/hawk-user-manager'; diff --git a/packages/core/src/users/hawk-user-manager.ts b/packages/core/src/users/hawk-user-manager.ts new file mode 100644 index 0000000..7c965c3 --- /dev/null +++ b/packages/core/src/users/hawk-user-manager.ts @@ -0,0 +1,71 @@ +import type { AffectedUser } from '@hawk.so/types'; +import type { HawkStorage } from '../storages/hawk-storage'; + +/** + * Storage key used to persist the auto-generated user ID. + */ +export const HAWK_USER_ID_KEY = 'hawk-user-id'; + +/** + * Manages the affected user identity. + * + * Manually provided users are kept in memory only (they don't change restarts). + * {@link HawkStorage} is used solely to persist the auto-generated ID + * so it survives across sessions. + */ +export class HawkUserManager { + /** + * In-memory user set explicitly via {@link setUser}. + */ + private user: AffectedUser | null = null; + + /** + * Underlying storage used to persist auto-generated user ID. + */ + private readonly storage: HawkStorage; + + /** + * @param storage - Storage backend to use for persistence. + */ + constructor(storage: HawkStorage) { + this.storage = storage; + } + + /** + * Returns the current affected user, or `null` if none is available. + * + * Priority: in-memory user > persisted user ID. + */ + public getUser(): AffectedUser | null { + if (this.user) { + return this.user; + } + const storedId = this.storage.getItem(HAWK_USER_ID_KEY); + return storedId ? { id: storedId } : null; + } + + /** + * Sets the user explicitly (in memory only). + * + * @param user - The affected user provided by the application. + */ + public setUser(user: AffectedUser): void { + this.user = user; + } + + /** + * Persists an auto-generated user ID to storage. + * + * @param id - The generated ID to persist. + */ + public persistGeneratedId(id: string): void { + this.storage.setItem(HAWK_USER_ID_KEY, id); + } + + /** + * Clears the explicitly set user, falling back to the persisted user ID. + */ + public clear(): void { + this.user = null; + } +} diff --git a/packages/core/tests/users/hawk-user-manager.test.ts b/packages/core/tests/users/hawk-user-manager.test.ts new file mode 100644 index 0000000..3ef9a72 --- /dev/null +++ b/packages/core/tests/users/hawk-user-manager.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { HawkUserManager } from '../../src'; +import type { HawkStorage } from '../../src'; + +describe('HawkUserManager', () => { + let storage: HawkStorage; + let manager: HawkUserManager; + + beforeEach(() => { + storage = { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + manager = new HawkUserManager(storage); + }); + + it('should return null when no user is set and storage is empty', () => { + expect(manager.getUser()).toBeNull(); + }); + + it('should return in-memory user set via setUser()', () => { + const user = { id: 'user-1', name: 'Ryan Gosling', url: 'https://example.com', photo: 'https://example.com/photo.png' }; + + manager.setUser(user); + + expect(manager.getUser()).toEqual(user); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('should not touch storage when setUser() is called', () => { + manager.setUser({ id: 'user-1' }); + + expect(storage.setItem).not.toHaveBeenCalled(); + expect(storage.removeItem).not.toHaveBeenCalled(); + }); + + it('should return anonymous user from storage when no in-memory user is set', () => { + vi.mocked(storage.getItem).mockReturnValue('anon-123'); + + expect(manager.getUser()).toEqual({ id: 'anon-123' }); + expect(storage.getItem).toHaveBeenCalledWith('hawk-user-id'); + }); + + it('should prefer in-memory user over persisted anonymous ID', () => { + vi.mocked(storage.getItem).mockReturnValue('anon-123'); + manager.setUser({ id: 'explicit-user' }); + + expect(manager.getUser()).toEqual({ id: 'explicit-user' }); + }); + + it('should persist anonymous ID via persistGeneratedId()', () => { + manager.persistGeneratedId('anon-456'); + + expect(storage.setItem).toHaveBeenCalledWith('hawk-user-id', 'anon-456'); + }); + + it('should clear in-memory user and fall back to persisted anonymous ID', () => { + vi.mocked(storage.getItem).mockReturnValue('anon-123'); + manager.setUser({ id: 'user-1' }); + manager.clear(); + + expect(manager.getUser()).toEqual({ id: 'anon-123' }); + }); + + it('should return null after clear() when no anonymous ID is persisted', () => { + manager.setUser({ id: 'user-1' }); + manager.clear(); + + expect(manager.getUser()).toBeNull(); + }); +}); diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json new file mode 100644 index 0000000..38db19a --- /dev/null +++ b/packages/core/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": null, + "declaration": false, + "types": ["vitest/globals"] + }, + "include": [ + "src/**/*", + "tests/**/*", + "vitest.config.ts" + ] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..aee2668 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['tests/**/*.test.ts'], + typecheck: { + tsconfig: './tsconfig.test.json', + }, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + }, + }, +}); diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index b18d868..63bcb29 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -4,7 +4,6 @@ import log from './utils/log'; import StackParser from './modules/stackParser'; import type { CatcherMessage, HawkInitialSettings, BreadcrumbsAPI, Transport } from './types'; import { VueIntegration } from './integrations/vue'; -import { id } from './utils/id'; import type { AffectedUser, EventContext, @@ -19,6 +18,9 @@ import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; import { validateUser, validateContext, isValidEventPayload } from './utils/validation'; +import { HawkUserManager } from '@hawk.so/core'; +import { HawkLocalStorage } from './storages/hawk-local-storage'; +import { id } from './utils/id'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -62,11 +64,6 @@ export default class Catcher { */ private readonly release: string | undefined; - /** - * Current authenticated user - */ - private user: AffectedUser; - /** * Any additional data passed by user for sending with all messages */ @@ -111,6 +108,11 @@ export default class Catcher { */ private readonly breadcrumbManager: BreadcrumbManager | null; + /** + * Current authenticated user manager instance + */ + private readonly userManager: HawkUserManager = new HawkUserManager(new HawkLocalStorage()); + /** * Catcher constructor * @@ -126,7 +128,9 @@ export default class Catcher { this.token = settings.token; this.debug = settings.debug || false; this.release = settings.release !== undefined ? String(settings.release) : undefined; - this.setUser(settings.user || Catcher.getGeneratedUser()); + if (settings.user) { + this.setUser(settings.user); + } this.setContext(settings.context || undefined); this.beforeSend = settings.beforeSend; this.disableVueErrorHandler = @@ -189,27 +193,6 @@ export default class Catcher { } } - /** - * Generates user if no one provided via HawkCatcher settings - * After generating, stores user for feature requests - */ - private static getGeneratedUser(): AffectedUser { - let userId: string; - const LOCAL_STORAGE_KEY = 'hawk-user-id'; - const storedId = localStorage.getItem(LOCAL_STORAGE_KEY); - - if (storedId) { - userId = storedId; - } else { - userId = id(); - localStorage.setItem(LOCAL_STORAGE_KEY, userId); - } - - return { - id: userId, - }; - } - /** * Send test event from client */ @@ -272,14 +255,14 @@ export default class Catcher { return; } - this.user = user; + this.userManager.setUser(user); } /** - * Clear current user information (revert to generated user) + * Clear current user information */ public clearUser(): void { - this.user = Catcher.getGeneratedUser(); + this.userManager.clear(); } /** @@ -565,10 +548,16 @@ export default class Catcher { } /** - * Current authenticated user + * Returns the current user if set, otherwise generates and persists an anonymous ID. */ - private getUser(): HawkJavaScriptEvent['user'] { - return this.user || null; + private getUser(): AffectedUser { + const user = this.userManager.getUser(); + if (user) { + return user; + } + const generatedId = id(); + this.userManager.persistGeneratedId(generatedId); + return { id: generatedId }; } /** diff --git a/packages/javascript/vite.config.ts b/packages/javascript/vite.config.ts index 47fe52e..65b622e 100644 --- a/packages/javascript/vite.config.ts +++ b/packages/javascript/vite.config.ts @@ -26,6 +26,7 @@ export default defineConfig(() => { fileName: 'hawk', }, rollupOptions: { + external: ['@hawk.so/core'], plugins: [ license({ thirdParty: { diff --git a/packages/javascript/vitest.config.ts b/packages/javascript/vitest.config.ts index 47ad6a2..68e2cda 100644 --- a/packages/javascript/vitest.config.ts +++ b/packages/javascript/vitest.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ alias: { '@/types': path.resolve(__dirname, './src/types'), }, + conditions: ['source'], }, }); diff --git a/yarn.lock b/yarn.lock index a2aacdf..65b88ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -587,8 +587,11 @@ __metadata: version: 0.0.0-use.local resolution: "@hawk.so/core@workspace:packages/core" dependencies: + "@hawk.so/types": "npm:0.5.8" + "@vitest/coverage-v8": "npm:^4.0.18" vite: "npm:^7.3.1" vite-plugin-dts: "npm:^4.2.4" + vitest: "npm:^4.0.18" languageName: unknown linkType: soft