diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts new file mode 100644 index 0000000..6149eff --- /dev/null +++ b/tests/i18n.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test'; +import i18next from 'i18next'; +import { t } from '../src/utils/i18n'; + +describe('i18n t()', () => { + test('returns a non-empty translated string for a known key in English', async () => { + await i18next.changeLanguage('en'); + const result = t('cancelled'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + expect(result).toBe('Cancelled'); + }); + + test('returns a non-empty translated string for a known key in Chinese', () => { + i18next.changeLanguage('zh'); + const result = t('cancelled'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + expect(result).toBe('已取消'); + }); + + test('returns a translated string for a key with interpolation in English', () => { + i18next.changeLanguage('en'); + const result = t('createAppSuccess', { id: '12345' }); + expect(typeof result).toBe('string'); + expect(result).toContain('12345'); + }); + + test('returns a translated string for a key with interpolation in Chinese', () => { + i18next.changeLanguage('zh'); + const result = t('createAppSuccess', { id: '67890' }); + expect(typeof result).toBe('string'); + expect(result).toContain('67890'); + }); + + test('handles multiple interpolation options', () => { + i18next.changeLanguage('en'); + const result = t('versionBind', { + version: '1.0.0', + nativeVersion: '2.0', + id: 'abc', + }); + expect(result).toContain('1.0.0'); + expect(result).toContain('2.0'); + expect(result).toContain('abc'); + }); + + test('returns the key itself or a fallback for an unknown key', async () => { + await i18next.changeLanguage('en'); + const result = t('this_key_does_not_exist_at_all'); + // i18next returns the key string when a key is missing + expect(result).toBe('this_key_does_not_exist_at_all'); + }); + + test('returns different strings for en and zh for the same key', () => { + i18next.changeLanguage('en'); + const enResult = t('packing'); + i18next.changeLanguage('zh'); + const zhResult = t('packing'); + // Both should be non-empty strings + expect(enResult.length).toBeGreaterThan(0); + expect(zhResult.length).toBeGreaterThan(0); + // They should differ (different languages) + expect(enResult).not.toBe(zhResult); + }); +}); diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts new file mode 100644 index 0000000..1f34f6b --- /dev/null +++ b/tests/plugin-config.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; + +import { plugins } from '../src/utils/plugin-config'; + +describe('plugin-config - sentry plugin', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-config-test-')); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + const sentryPlugin = plugins.find((p) => p.name === 'sentry'); + + test('sentry plugin exists in the plugins array', () => { + expect(sentryPlugin).toBeDefined(); + }); + + test('sentry bundleParams are { sentry: true, sourcemap: true }', () => { + expect(sentryPlugin?.bundleParams).toEqual({ + sentry: true, + sourcemap: true, + }); + }); + + describe('sentry detect', () => { + test('returns false when no sentry.properties exists', async () => { + const origCwd = process.cwd(); + process.chdir(tmpDir); + try { + const result = await sentryPlugin?.detect(); + expect(result).toBe(false); + } finally { + process.chdir(origCwd); + } + }); + + test('returns true when ios/sentry.properties exists', async () => { + await fs.ensureDir(path.join(tmpDir, 'ios')); + await fs.writeFile( + path.join(tmpDir, 'ios', 'sentry.properties'), + 'defaults.org=test\n', + ); + + const origCwd = process.cwd(); + process.chdir(tmpDir); + try { + const result = await sentryPlugin?.detect(); + expect(result).toBe(true); + } finally { + process.chdir(origCwd); + } + }); + + test('returns true when android/sentry.properties exists', async () => { + await fs.ensureDir(path.join(tmpDir, 'android')); + await fs.writeFile( + path.join(tmpDir, 'android', 'sentry.properties'), + 'defaults.org=test\n', + ); + + const origCwd = process.cwd(); + process.chdir(tmpDir); + try { + const result = await sentryPlugin?.detect(); + expect(result).toBe(true); + } finally { + process.chdir(origCwd); + } + }); + + test('returns true when both ios and android sentry.properties exist', async () => { + await fs.ensureDir(path.join(tmpDir, 'ios')); + await fs.ensureDir(path.join(tmpDir, 'android')); + await fs.writeFile( + path.join(tmpDir, 'ios', 'sentry.properties'), + 'defaults.org=test\n', + ); + await fs.writeFile( + path.join(tmpDir, 'android', 'sentry.properties'), + 'defaults.org=test\n', + ); + + const origCwd = process.cwd(); + process.chdir(tmpDir); + try { + const result = await sentryPlugin?.detect(); + expect(result).toBe(true); + } finally { + process.chdir(origCwd); + } + }); + }); +}); diff --git a/tests/user.test.ts b/tests/user.test.ts new file mode 100644 index 0000000..19c9035 --- /dev/null +++ b/tests/user.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import crypto from 'crypto'; + +import * as api from '../src/api'; +import { userCommands } from '../src/user'; +import * as utils from '../src/utils'; + +function md5(str: string) { + return crypto.createHash('md5').update(str).digest('hex'); +} + +describe('userCommands.login', () => { + let consoleSpy: ReturnType; + let postSpy: ReturnType; + let replaceSessionSpy: ReturnType; + let saveSessionSpy: ReturnType; + let questionSpy: ReturnType; + + beforeEach(() => { + consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + postSpy = spyOn(api, 'post').mockResolvedValue({ + token: 'session-token-abc', + info: { name: 'TestUser', email: 'test@example.com' }, + }); + replaceSessionSpy = spyOn(api, 'replaceSession').mockImplementation( + () => {}, + ); + saveSessionSpy = spyOn(api, 'saveSession').mockResolvedValue(undefined); + questionSpy = spyOn(utils, 'question').mockResolvedValue('fallback'); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + postSpy.mockRestore(); + replaceSessionSpy.mockRestore(); + saveSessionSpy.mockRestore(); + questionSpy.mockRestore(); + }); + + test('calls post with /user/login and md5-hashes the password', async () => { + await userCommands.login({ + args: ['user@example.com', 'mypassword'], + }); + + expect(postSpy).toHaveBeenCalledWith('/user/login', { + email: 'user@example.com', + pwd: md5('mypassword'), + }); + }); + + test('md5 hash is a valid 32-char hex string', async () => { + await userCommands.login({ + args: ['user@example.com', 'secret123'], + }); + + const callArgs = postSpy.mock.calls[0]; + const pwdHash = callArgs[1].pwd as string; + expect(pwdHash).toHaveLength(32); + expect(pwdHash).toMatch(/^[0-9a-f]{32}$/); + expect(pwdHash).toBe(md5('secret123')); + }); + + test('calls replaceSession with the returned token', async () => { + await userCommands.login({ + args: ['user@example.com', 'mypassword'], + }); + + expect(replaceSessionSpy).toHaveBeenCalledWith({ + token: 'session-token-abc', + }); + }); + + test('calls saveSession after replaceSession', async () => { + await userCommands.login({ + args: ['user@example.com', 'mypassword'], + }); + + expect(saveSessionSpy).toHaveBeenCalled(); + expect(replaceSessionSpy).toHaveBeenCalled(); + + // Verify call order: replaceSession should be called before saveSession + const replaceOrder = replaceSessionSpy.mock.invocationCallOrder[0]; + const saveOrder = saveSessionSpy.mock.invocationCallOrder[0]; + expect(replaceOrder).toBeLessThan(saveOrder); + }); + + test('prompts for email and password when args are missing', async () => { + let _callCount = 0; + questionSpy.mockImplementation(async (prompt: string) => { + _callCount++; + if (prompt === 'email:') return 'asked@email.com'; + return 'asked-password'; + }); + + await userCommands.login({ args: [] }); + + expect(questionSpy).toHaveBeenCalledTimes(2); + expect(postSpy).toHaveBeenCalledWith('/user/login', { + email: 'asked@email.com', + pwd: md5('asked-password'), + }); + }); + + test('logs welcome message with user name', async () => { + await userCommands.login({ + args: ['user@example.com', 'mypassword'], + }); + + expect(consoleSpy).toHaveBeenCalled(); + // The welcome message includes the user's name + const logOutput = consoleSpy.mock.calls[0][0] as string; + expect(logOutput).toContain('TestUser'); + }); +}); + +describe('userCommands.logout', () => { + let consoleSpy: ReturnType; + let closeSessionSpy: ReturnType; + + beforeEach(() => { + consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + closeSessionSpy = spyOn(api, 'closeSession').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + closeSessionSpy.mockRestore(); + }); + + test('calls closeSession', async () => { + await userCommands.logout({} as any); + + expect(closeSessionSpy).toHaveBeenCalled(); + }); + + test('logs a message after logout', async () => { + await userCommands.logout({} as any); + + expect(consoleSpy).toHaveBeenCalled(); + }); +}); + +describe('userCommands.me', () => { + let consoleSpy: ReturnType; + let getSpy: ReturnType; + + beforeEach(() => { + consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + getSpy = spyOn(api, 'get').mockResolvedValue({ + ok: true, + name: 'TestUser', + email: 'test@example.com', + id: '12345', + }); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + getSpy.mockRestore(); + }); + + test('calls get with /user/me', async () => { + await userCommands.me(); + + expect(getSpy).toHaveBeenCalledWith('/user/me'); + }); + + test('logs each field except "ok"', async () => { + await userCommands.me(); + + // Should log name, email, id but NOT ok + const logCalls = consoleSpy.mock.calls.map((c) => c[0] as string); + expect(logCalls).toContain('name: TestUser'); + expect(logCalls).toContain('email: test@example.com'); + expect(logCalls).toContain('id: 12345'); + + // Should not log the "ok" field + const hasOk = logCalls.some((msg) => msg.startsWith('ok:')); + expect(hasOk).toBe(false); + }); + + test('skips the "ok" field when logging', async () => { + await userCommands.me(); + + const logCalls = consoleSpy.mock.calls.map((c) => c[0] as string); + for (const msg of logCalls) { + expect(msg).not.toMatch(/^ok:/); + } + }); +}); diff --git a/tests/zip-options-file.test.ts b/tests/zip-options-file.test.ts new file mode 100644 index 0000000..0a476e3 --- /dev/null +++ b/tests/zip-options-file.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { zipOptionsForPayloadFile } from '../src/utils/zip-options'; + +describe('zipOptionsForPayloadFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zip-options-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('returns compress=false for a real PNG file (magic bytes)', () => { + const pngFile = path.join(tmpDir, 'image.png'); + // PNG magic: 89 50 4E 47 0D 0A 1A 0A + const pngMagic = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, + ]); + fs.writeFileSync(pngFile, pngMagic); + const result = zipOptionsForPayloadFile(pngFile); + expect(result).toEqual({ compress: false }); + }); + + test('returns compress=true with compressionLevel:9 for a JS file', () => { + const jsFile = path.join(tmpDir, 'bundle.js'); + fs.writeFileSync(jsFile, 'console.log("hello world");\n'); + const result = zipOptionsForPayloadFile(jsFile); + expect(result).toEqual({ compress: true, compressionLevel: 9 }); + }); + + test('returns compress=true for Hermes bytecode magic bytes', () => { + const hermesFile = path.join(tmpDir, 'index.bundlejs'); + // Hermes bytecode magic: c6 1f bc 03 c1 03 19 1f + const hermesMagic = Buffer.from([ + 0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + ]); + fs.writeFileSync(hermesFile, hermesMagic); + const result = zipOptionsForPayloadFile(hermesFile); + expect(result).toEqual({ compress: true, compressionLevel: 9 }); + }); + + test('returns compress=false for a JPEG file (magic bytes)', () => { + const jpegFile = path.join(tmpDir, 'photo.jpg'); + // JPEG magic: FF D8 FF + const jpegMagic = Buffer.from([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, + 0x01, 0x00, 0x00, 0x01, + ]); + fs.writeFileSync(jpegFile, jpegMagic); + const result = zipOptionsForPayloadFile(jpegFile); + expect(result).toEqual({ compress: false }); + }); + + test('returns compress=true for unknown content with .txt extension', () => { + const txtFile = path.join(tmpDir, 'readme.txt'); + fs.writeFileSync(txtFile, 'This is a plain text file with some content.\n'); + const result = zipOptionsForPayloadFile(txtFile); + expect(result).toEqual({ compress: true, compressionLevel: 9 }); + }); + + test('handles empty file (no magic bytes, unknown extension)', () => { + const emptyFile = path.join(tmpDir, 'empty.dat'); + fs.writeFileSync(emptyFile, Buffer.alloc(0)); + const result = zipOptionsForPayloadFile(emptyFile); + // Empty file: no magic bytes detected, .dat is not in the compressed set, + // so it falls through to compress=true with level 9 + expect(result).toEqual({ compress: true, compressionLevel: 9 }); + }); + + test('respects custom entryName for extension-based detection', () => { + const dataFile = path.join(tmpDir, 'asset'); + // Write some random non-magic content + fs.writeFileSync(dataFile, Buffer.from([0x01, 0x02, 0x03, 0x04])); + // Provide an entryName with a compressed extension + const result = zipOptionsForPayloadFile(dataFile, 'icon.png'); + expect(result).toEqual({ compress: false }); + }); + + test('uses filePath extension when no entryName is given', () => { + const mp4File = path.join(tmpDir, 'video.mp4'); + fs.writeFileSync(mp4File, Buffer.from([0x00, 0x00, 0x00, 0x1c])); + const result = zipOptionsForPayloadFile(mp4File); + expect(result).toEqual({ compress: false }); + }); +});