diff --git a/.github/workflows/usdk-test.yml b/.github/workflows/usdk-test.yml index 34ad65d80..3b0492d7c 100644 --- a/.github/workflows/usdk-test.yml +++ b/.github/workflows/usdk-test.yml @@ -26,5 +26,5 @@ jobs: - name: Install dependencies run: cd packages/usdk && pnpm install --no-optional --no-frozen-lockfile - - name: Run USDK command - run: cd packages/usdk && ./usdk.js --version + - name: Run USDK tests + run: cd packages/usdk && pnpm run test \ No newline at end of file diff --git a/packages/usdk/jest.config.mjs b/packages/usdk/jest.config.mjs new file mode 100644 index 000000000..426948dd1 --- /dev/null +++ b/packages/usdk/jest.config.mjs @@ -0,0 +1,5 @@ +export default { + testEnvironment: 'node', + moduleFileExtensions: ['js', 'mjs'], + testMatch: ['**/tests/**/*.test.js'], +}; diff --git a/packages/usdk/package.json b/packages/usdk/package.json index 4fde58ea3..f4480a3a1 100644 --- a/packages/usdk/package.json +++ b/packages/usdk/package.json @@ -18,7 +18,8 @@ "type": "module", "main": "module.mjs", "scripts": { - "preinstall": "[[ $npm_config_global == 'true' ]] || exit 0; cd packages/upstreet-agent && pnpm install --ignore-workspace --no-frozen-lockfile" + "preinstall": "[[ $npm_config_global == 'true' ]] || exit 0; cd packages/upstreet-agent && pnpm install --ignore-workspace --no-frozen-lockfile", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" }, "keywords": [ "ai", @@ -85,7 +86,6 @@ "gitignore-parser": "0.0.2", "hono": "^4.6.9", "javascript-time-ago": "^2.5.11", - "jest": "^29.7.0", "jimp": "^1.6.0", "json5": "^2.2.3", "jszip": "^3.10.1", diff --git a/packages/usdk/tests/commands/login.test.js b/packages/usdk/tests/commands/login.test.js new file mode 100644 index 000000000..f62bf5372 --- /dev/null +++ b/packages/usdk/tests/commands/login.test.js @@ -0,0 +1,28 @@ +import { login } from '../../lib/login.mjs'; +import { jest } from '@jest/globals'; + +describe('login', () => { + + it('should successfully login when valid code parameter is provided', async () => { + const mockLoginData = { + id: 'test-id', + jwt: 'test-jwt' + }; + const encodedData = Buffer.from(JSON.stringify(mockLoginData)).toString('base64'); + + const result = await login({ code: encodedData }); + + expect(result).toEqual({ + id: 'test-id', + jwt: 'test-jwt' + }); + }); + + it('should reject when invalid base64 code is provided', async () => { + const invalidCode = 'invalid-base64-!@#$'; + + await expect(async () => { + await login({ code: invalidCode }); + }).rejects.toThrow(SyntaxError); + }); +}); diff --git a/packages/usdk/tests/commands/logout.test.js b/packages/usdk/tests/commands/logout.test.js new file mode 100644 index 000000000..5b027b81e --- /dev/null +++ b/packages/usdk/tests/commands/logout.test.js @@ -0,0 +1,57 @@ +import { jest } from '@jest/globals'; + +// We need to mock before importing the module that uses it +const mockGetLoginJwt = jest.fn(); +jest.unstable_mockModule('../../util/login-util.mjs', () => ({ + getLoginJwt: mockGetLoginJwt +})); + +// Import after mocking +const { logout } = await import('../../lib/login.mjs'); + +describe('logout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return true when jwt exists', async () => { + mockGetLoginJwt.mockResolvedValue('mock-jwt-token'); + + const result = await logout({}); + + expect(result).toBe(true); + expect(mockGetLoginJwt).toHaveBeenCalledTimes(1); + }); + + it('should return false when jwt is empty', async () => { + mockGetLoginJwt.mockResolvedValue(''); + + const result = await logout({}); + + expect(result).toBe(false); + expect(mockGetLoginJwt).toHaveBeenCalledTimes(1); + }); + + it('should return false when jwt is null', async () => { + mockGetLoginJwt.mockResolvedValue(null); + + const result = await logout({}); + + expect(result).toBe(false); + expect(mockGetLoginJwt).toHaveBeenCalledTimes(1); + }); + + it('should return false when jwt is undefined', async () => { + const result = await logout({}); + + expect(result).toBe(false); + expect(mockGetLoginJwt).toHaveBeenCalledTimes(1); + }); + + it('should handle rejected jwt promise', async () => { + mockGetLoginJwt.mockRejectedValue(new Error('Failed to get JWT')); + + await expect(logout({})).rejects.toThrow('Failed to get JWT'); + expect(mockGetLoginJwt).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/packages/usdk/tests/commands/pull.test.js b/packages/usdk/tests/commands/pull.test.js new file mode 100644 index 000000000..6eb8d3410 --- /dev/null +++ b/packages/usdk/tests/commands/pull.test.js @@ -0,0 +1,143 @@ +import { jest } from '@jest/globals'; + +// Mock all dependencies before importing modules +const mockGetUserIdForJwt = jest.fn(); +const mockCleanDir = jest.fn(); +const mockExtractZip = jest.fn(); +const mockNpmInstall = jest.fn(); +const mockFetch = jest.fn(); + +jest.unstable_mockModule('../../packages/upstreet-agent/packages/react-agents/util/jwt-utils.mjs', () => ({ + getUserIdForJwt: mockGetUserIdForJwt +})); + +jest.unstable_mockModule('../../lib/directory-util.mjs', () => ({ + cleanDir: mockCleanDir +})); + +jest.unstable_mockModule('../../lib/zip-util.mjs', () => ({ + extractZip: mockExtractZip +})); + +jest.unstable_mockModule('../../lib/npm-util.mjs', () => ({ + npmInstall: mockNpmInstall +})); + +// Import modules after mocking +const { pull } = await import('../../lib/pull.mjs'); +const { aiProxyHost } = await import('../../packages/upstreet-agent/packages/react-agents/util/endpoints.mjs'); + +describe('pull command', () => { + // Setup and teardown + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + global.fetch = mockFetch; + global.console.log = jest.fn(); + global.console.warn = jest.fn(); + }); + + test('should throw error if not logged in', async () => { + const args = { _: ['agentId'] }; + const opts = { jwt: null }; + + await expect(pull(args, opts)).rejects.toThrow('You must be logged in to pull.'); + }); + + test('should handle successful pull with existing directory', async () => { + // Mock setup + mockGetUserIdForJwt.mockResolvedValue('testUserId'); + mockFetch.mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }); + + const args = { + _: ['agentId', '/test/dir'], + force: true, + forceNoConfirm: true, + }; + const opts = { jwt: 'testJwt' }; + + await pull(args, opts); + + // Verify calls + expect(mockCleanDir).toHaveBeenCalledWith('/test/dir', { + force: true, + forceNoConfirm: true, + }); + expect(mockExtractZip).toHaveBeenCalled(); + expect(mockNpmInstall).toHaveBeenCalledWith('/test/dir'); + expect(mockFetch).toHaveBeenCalledWith( + `https://${aiProxyHost}/agents/agentId/source`, + { + headers: { + Authorization: 'Bearer testJwt', + }, + } + ); + }); + + test('should skip npm install when noInstall flag is true', async () => { + // Mock setup + mockGetUserIdForJwt.mockResolvedValue('testUserId'); + mockFetch.mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }); + + const args = { + _: ['agentId', '/test/dir'], + noInstall: true, + }; + const opts = { jwt: 'testJwt' }; + + await pull(args, opts); + + expect(mockNpmInstall).not.toHaveBeenCalled(); + }); + + test('should handle failed fetch request', async () => { + // Mock setup + mockGetUserIdForJwt.mockResolvedValue('testUserId'); + mockFetch.mockResolvedValue({ + ok: false, + text: () => Promise.resolve('Error message'), + }); + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + + const args = { _: ['agentId', '/test/dir'] }; + const opts = { jwt: 'testJwt' }; + + await pull(args, opts); + + expect(console.warn).toHaveBeenCalledWith('pull request error', 'Error message'); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + test('should handle events dispatch', async () => { + // Mock setup + mockGetUserIdForJwt.mockResolvedValue('testUserId'); + mockFetch.mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }); + + const mockDispatchEvent = jest.fn(); + const args = { + _: ['agentId', '/test/dir'], + events: { + dispatchEvent: mockDispatchEvent, + }, + }; + const opts = { jwt: 'testJwt' }; + + await pull(args, opts); + + expect(mockDispatchEvent).toHaveBeenCalledWith( + expect.any(MessageEvent) + ); + }); +}); \ No newline at end of file diff --git a/packages/usdk/tests/commands/status.test.js b/packages/usdk/tests/commands/status.test.js new file mode 100644 index 000000000..a16394598 --- /dev/null +++ b/packages/usdk/tests/commands/status.test.js @@ -0,0 +1,114 @@ +import { jest } from '@jest/globals'; + +// Mock dependencies before importing the module that uses them +const mockGetUserIdForJwt = jest.fn(); +const mockSupabaseStorage = jest.fn(); + +jest.unstable_mockModule('../../packages/upstreet-agent/packages/react-agents/util/jwt-utils.mjs', () => ({ + getUserIdForJwt: mockGetUserIdForJwt +})); + +jest.unstable_mockModule('../../packages/upstreet-agent/packages/react-agents/storage/supabase-storage.mjs', () => ({ + SupabaseStorage: mockSupabaseStorage +})); + +// Import after mocking +const { status } = await import('../../lib/status.mjs'); + +describe('status', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('throws error when no JWT provided', async () => { + await expect(status({}, {})).rejects.toThrow('not logged in'); + }); + + test('returns user and wearing data when user has active asset', async () => { + const mockJwt = 'mock-jwt'; + const mockUserId = 'user-123'; + const mockUserData = { + id: mockUserId, + active_asset: 'asset-123', + }; + const mockAssetData = { + id: 'asset-123', + type: 'npc', + name: 'Test Avatar', + }; + + mockGetUserIdForJwt.mockResolvedValue(mockUserId); + + const mockSupabase = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn(), + }; + + mockSupabaseStorage.mockImplementation(() => mockSupabase); + + mockSupabase.maybeSingle + .mockResolvedValueOnce({ error: null, data: mockUserData }) + .mockResolvedValueOnce({ error: null, data: mockAssetData }); + + const result = await status({}, { jwt: mockJwt }); + + expect(result).toEqual({ + user: mockUserData, + wearing: mockAssetData, + }); + expect(mockGetUserIdForJwt).toHaveBeenCalledWith(mockJwt); + expect(mockSupabaseStorage).toHaveBeenCalledWith({ jwt: mockJwt }); + }); + + test('returns only user data when user has no active asset', async () => { + const mockJwt = 'mock-jwt'; + const mockUserId = 'user-123'; + const mockUserData = { + id: mockUserId, + active_asset: null, + }; + + mockGetUserIdForJwt.mockResolvedValue(mockUserId); + + const mockSupabase = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn(), + }; + + mockSupabaseStorage.mockImplementation(() => mockSupabase); + mockSupabase.maybeSingle.mockResolvedValueOnce({ error: null, data: mockUserData }); + + const result = await status({}, { jwt: mockJwt }); + + expect(result).toEqual({ + user: mockUserData, + wearing: null, + }); + }); + + test('throws error when account query fails', async () => { + const mockJwt = 'mock-jwt'; + const mockUserId = 'user-123'; + const mockError = 'Database error'; + + mockGetUserIdForJwt.mockResolvedValue(mockUserId); + + const mockSupabase = { + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn(), + }; + + mockSupabaseStorage.mockImplementation(() => mockSupabase); + mockSupabase.maybeSingle.mockResolvedValueOnce({ error: mockError, data: null }); + + await expect(status({}, { jwt: mockJwt })) + .rejects + .toThrow(`could not get account ${mockUserId}: ${mockError}`); + }); +}); \ No newline at end of file diff --git a/packages/usdk/tests/commands/version.test.js b/packages/usdk/tests/commands/version.test.js new file mode 100644 index 000000000..4a3983975 --- /dev/null +++ b/packages/usdk/tests/commands/version.test.js @@ -0,0 +1,22 @@ +import { version } from '../../lib/version.mjs'; +import { readFile } from 'fs/promises'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe('usdk version command', () => { + it('should return a valid version string', async () => { + const ver = version(); + expect(typeof ver).toBe('string'); + expect(ver).toMatch(/^\d+\.\d+\.\d+/); + }); + + it('should match the package.json version', async () => { + const ver = version(); + const packageJson = JSON.parse( + await readFile(resolve(__dirname, '../../package.json'), 'utf8') + ); + expect(ver).toBe(packageJson.version); + }); +}); \ No newline at end of file