diff --git a/.github/workflows/jan-electron-build-beta.yml b/.github/workflows/jan-electron-build-beta.yml index 75114555dc..6468743dd3 100644 --- a/.github/workflows/jan-electron-build-beta.yml +++ b/.github/workflows/jan-electron-build-beta.yml @@ -74,7 +74,7 @@ jobs: new_version: ${{ needs.get-update-version.outputs.new_version }} beta: true - combine-latest-mac-yml: + combine-beta-mac-yml: needs: [build-macos-x64, build-macos-arm64, create-draft-release] runs-on: ubuntu-latest permissions: @@ -86,26 +86,26 @@ jobs: - name: Download mac-x64 artifacts uses: actions/download-artifact@v4 with: - name: latest-mac-x64 - path: ./latest-mac-x64 + name: beta-mac-x64 + path: ./beta-mac-x64 - name: Download mac-arm artifacts uses: actions/download-artifact@v4 with: - name: latest-mac-arm64 - path: ./latest-mac-arm64 + name: beta-mac-arm64 + path: ./beta-mac-arm64 - - name: 'Merge latest-mac.yml' + - name: 'Merge beta-mac.yml' # unfortunately electron-builder doesn't understand that we have two different releases for mac-x64 and mac-arm, so we need to manually merge the latest files # see https://github.com/electron-userland/electron-builder/issues/5592 run: | ls -la . - ls -la ./latest-mac-x64 - ls -la ./latest-mac-arm64 + ls -la ./beta-mac-x64 + ls -la ./beta-mac-arm64 ls -la ./electron - cp ./electron/merge-latest-ymls.js /tmp/merge-latest-ymls.js + cp ./electron/merge-latest-ymls.js /tmp/merge-beta-ymls.js npm install js-yaml --prefix /tmp - node /tmp/merge-latest-ymls.js ./latest-mac-x64/latest-mac.yml ./latest-mac-arm64/latest-mac.yml ./latest-mac.yml - cat ./latest-mac.yml + node /tmp/merge-beta-ymls.js ./beta-mac-x64/beta-mac.yml ./beta-mac-arm64/beta-mac.yml ./beta-mac.yml + cat ./beta-mac.yml - name: Yet Another Upload Release Asset Action uses: shogo82148/actions-upload-release-asset@v1.7.2 @@ -113,8 +113,24 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-draft-release.outputs.upload_url }} - asset_path: ./latest-mac.yml - asset_name: latest-mac.yml + asset_path: ./beta-mac.yml + asset_name: beta-mac.yml asset_content_type: text/yaml overwrite: true + - name: Upload beta-mac.yml + run: | + aws s3 cp ./beta-mac.yml "s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-beta/beta-mac.yml" + # sync temp-beta to beta by copy files that are different or new + aws s3 sync "s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-beta/" "s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/beta/" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.DELTA_AWS_REGION }} + AWS_EC2_METADATA_DISABLED: "true" + + - name: set release to prerelease + run: | + gh release edit v${{ needs.create-draft-release.outputs.version }} --prerelease + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/template-build-linux-x64.yml b/.github/workflows/template-build-linux-x64.yml index a7284b4a95..6fc3b2aa29 100644 --- a/.github/workflows/template-build-linux-x64.yml +++ b/.github/workflows/template-build-linux-x64.yml @@ -76,6 +76,9 @@ jobs: cat ./electron/package.json echo "------------------------" cat ./package.json + jq '.build.publish = [{"provider": "github", "owner": "janhq", "repo": "jan", "channel": "beta"}, {"provider": "generic", "url": "https://delta.jan.ai/beta", "channel": "beta"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-beta", "channel": "beta"}]' electron/package.json > /tmp/package.json + mv /tmp/package.json electron/package.json + cat electron/package.json - name: Update app version base on tag if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' @@ -105,7 +108,7 @@ jobs: AWS_MAX_ATTEMPTS: "5" - name: Build and publish app to github - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false run: | make build-and-publish env: @@ -113,6 +116,17 @@ jobs: ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }} ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }} + - name: Build and publish app to github + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true + run: | + make build-and-publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }} + AWS_EC2_METADATA_DISABLED: "true" + AWS_MAX_ATTEMPTS: "5" + - name: Upload Artifact .deb file if: inputs.public_provider != 'github' uses: actions/upload-artifact@v4 diff --git a/.github/workflows/template-build-macos-arm64.yml b/.github/workflows/template-build-macos-arm64.yml index 629fd596ae..5e5a930b20 100644 --- a/.github/workflows/template-build-macos-arm64.yml +++ b/.github/workflows/template-build-macos-arm64.yml @@ -92,6 +92,9 @@ jobs: cat ./electron/package.json echo "------------------------" cat ./package.json + jq '.build.publish = [{"provider": "github", "owner": "janhq", "repo": "jan", "channel": "beta"}, {"provider": "generic", "url": "https://delta.jan.ai/beta", "channel": "beta"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-beta", "channel": "beta"}]' electron/package.json > /tmp/package.json + mv /tmp/package.json electron/package.json + cat electron/package.json - name: Update app version base on tag if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' @@ -144,7 +147,7 @@ jobs: AWS_MAX_ATTEMPTS: "5" - name: Build and publish app to github - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false run: | make build-and-publish env: @@ -159,6 +162,25 @@ jobs: ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }} ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }} + - name: Build and publish app to github + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true + run: | + make build-and-publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: "/tmp/codesign.p12" + CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }} + CSC_IDENTITY_AUTO_DISCOVERY: "true" + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APP_PATH: "." + DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: "true" + AWS_MAX_ATTEMPTS: "5" + - name: Upload Artifact if: inputs.public_provider != 'github' uses: actions/upload-artifact@v4 @@ -167,7 +189,15 @@ jobs: path: ./electron/dist/jan-mac-arm64-${{ inputs.new_version }}.dmg - name: Upload Artifact + if: inputs.beta == false uses: actions/upload-artifact@v4 with: name: latest-mac-arm64 - path: ./electron/dist/latest-mac.yml \ No newline at end of file + path: ./electron/dist/latest-mac.yml + + - name: Upload Artifact + if: inputs.beta == true + uses: actions/upload-artifact@v4 + with: + name: beta-mac-arm64 + path: ./electron/dist/beta-mac.yml \ No newline at end of file diff --git a/.github/workflows/template-build-macos-x64.yml b/.github/workflows/template-build-macos-x64.yml index f86bbfa48a..260cea8947 100644 --- a/.github/workflows/template-build-macos-x64.yml +++ b/.github/workflows/template-build-macos-x64.yml @@ -92,6 +92,9 @@ jobs: cat ./electron/package.json echo "------------------------" cat ./package.json + jq '.build.publish = [{"provider": "github", "owner": "janhq", "repo": "jan", "channel": "beta"}, {"provider": "generic", "url": "https://delta.jan.ai/beta", "channel": "beta"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-beta", "channel": "beta"}]' electron/package.json > /tmp/package.json + mv /tmp/package.json electron/package.json + cat electron/package.json - name: Update app version base on tag if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' @@ -144,7 +147,7 @@ jobs: AWS_MAX_ATTEMPTS: "5" - name: Build and publish app to github - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false run: | make build-and-publish env: @@ -159,6 +162,25 @@ jobs: ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }} ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }} + - name: Build and publish app to github + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true + run: | + make build-and-publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: "/tmp/codesign.p12" + CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }} + CSC_IDENTITY_AUTO_DISCOVERY: "true" + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APP_PATH: "." + DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: "true" + AWS_MAX_ATTEMPTS: "5" + - name: Upload Artifact if: inputs.public_provider != 'github' uses: actions/upload-artifact@v4 @@ -167,7 +189,15 @@ jobs: path: ./electron/dist/jan-mac-x64-${{ inputs.new_version }}.dmg - name: Upload Artifact + if: inputs.beta == false uses: actions/upload-artifact@v4 with: name: latest-mac-x64 - path: ./electron/dist/latest-mac.yml \ No newline at end of file + path: ./electron/dist/latest-mac.yml + + - name: Upload Artifact + if: inputs.beta == true + uses: actions/upload-artifact@v4 + with: + name: beta-mac-x64 + path: ./electron/dist/beta-mac.yml \ No newline at end of file diff --git a/.github/workflows/template-build-windows-x64.yml b/.github/workflows/template-build-windows-x64.yml index 7fa7696301..2ca4cee943 100644 --- a/.github/workflows/template-build-windows-x64.yml +++ b/.github/workflows/template-build-windows-x64.yml @@ -96,6 +96,9 @@ jobs: cat ./package.json echo "------------------------" cat ./electron/scripts/uninstaller.nsh + jq '.build.publish = [{"provider": "github", "owner": "janhq", "repo": "jan", "channel": "beta"}, {"provider": "generic", "url": "https://delta.jan.ai/beta", "channel": "beta"}, {"provider": "s3", "acl": null, "bucket": "${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}", "region": "${{ secrets.DELTA_AWS_REGION}}", "path": "temp-beta", "channel": "beta"}]' electron/package.json > /tmp/package.json + mv /tmp/package.json electron/package.json + cat electron/package.json - name: Update app version base on tag if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' @@ -138,7 +141,7 @@ jobs: AWS_MAX_ATTEMPTS: "5" - name: Build app and publish app to github - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false run: | make build-and-publish env: @@ -151,6 +154,23 @@ jobs: AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} AZURE_CERT_NAME: ${{ secrets.AZURE_CERT_NAME }} + - name: Build app and publish app to github + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true + run: | + make build-and-publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: "true" + AWS_MAX_ATTEMPTS: "5" + AZURE_KEY_VAULT_URI: ${{ secrets.AZURE_KEY_VAULT_URI }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_CERT_NAME: ${{ secrets.AZURE_CERT_NAME }} + - name: Upload Artifact if: inputs.public_provider != 'github' uses: actions/upload-artifact@v4 diff --git a/electron/utils/setup.ts b/electron/utils/setup.ts index 437e21f977..39b8a41335 100644 --- a/electron/utils/setup.ts +++ b/electron/utils/setup.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { app, screen } from 'electron' import Store from 'electron-store' const DEFAULT_WIDTH = 1000 @@ -22,13 +22,42 @@ export const getBounds = async () => { height: DEFAULT_HEIGHT, } - const bounds = await storage.get('windowBounds') - if (bounds) { - return bounds as Electron.Rectangle - } else { + const bounds = (await storage.get('windowBounds')) as + | Electron.Rectangle + | undefined + + // If no bounds are saved, use the defaults + if (!bounds) { storage.set('windowBounds', defaultBounds) return defaultBounds } + + // Validate that the bounds are on a valid display + const displays = screen.getAllDisplays() + const isValid = displays.some((display) => { + const { x, y, width, height } = display.bounds + return ( + bounds.x >= x && + bounds.x < x + width && + bounds.y >= y && + bounds.y < y + height + ) + }) + + // If the position is valid, return the saved bounds, otherwise return default bounds + if (isValid) { + return bounds + } else { + const primaryDisplay = screen.getPrimaryDisplay() + const resetBounds = { + x: primaryDisplay.bounds.x, + y: primaryDisplay.bounds.y, + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + } + storage.set('windowBounds', resetBounds) + return resetBounds + } } export const saveBounds = (bounds: Electron.Rectangle | undefined) => { diff --git a/joi/src/core/Modal/styles.scss b/joi/src/core/Modal/styles.scss index fcbf071057..11af9418ae 100644 --- a/joi/src/core/Modal/styles.scss +++ b/joi/src/core/Modal/styles.scss @@ -13,7 +13,7 @@ fieldset, &__content { color: hsla(var(--modal-fg)); - overflow: hidden; + overflow: auto; background-color: hsla(var(--modal-bg)); border-radius: 8px; font-size: 14px; diff --git a/web/containers/AutoLink/index.test.tsx b/web/containers/AutoLink/index.test.tsx new file mode 100644 index 0000000000..9f4610a80c --- /dev/null +++ b/web/containers/AutoLink/index.test.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import AutoLink from './index' + +describe('AutoLink Component', () => { + it('renders text without links correctly', () => { + const text = 'This is a test without links.' + render() + expect(screen.getByText(text)).toBeInTheDocument() + }) + + it('renders text with a single link correctly', () => { + const text = 'Check this link: https://example.com' + render() + const link = screen.getByText('https://example.com') + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', 'https://example.com') + expect(link).toHaveAttribute('target', 'blank') + }) + + it('renders text with multiple links correctly', () => { + const text = 'Visit https://example.com and http://test.com' + render() + const link1 = screen.getByText('https://example.com') + const link2 = screen.getByText('http://test.com') + expect(link1).toBeInTheDocument() + expect(link1).toHaveAttribute('href', 'https://example.com') + expect(link1).toHaveAttribute('target', 'blank') + expect(link2).toBeInTheDocument() + expect(link2).toHaveAttribute('href', 'http://test.com') + expect(link2).toHaveAttribute('target', 'blank') + }) + + it('renders text with a link without protocol correctly', () => { + const text = 'Visit example.com for more info.' + render() + const link = screen.getByText('example.com') + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', 'http://example.com') + expect(link).toHaveAttribute('target', 'blank') + }) +}) diff --git a/web/containers/BlankState/index.test.tsx b/web/containers/BlankState/index.test.tsx new file mode 100644 index 0000000000..53cb2ece74 --- /dev/null +++ b/web/containers/BlankState/index.test.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import BlankState from './index' + +describe('BlankState Component', () => { + it('renders title correctly', () => { + const title = 'Test Title' + render() + expect(screen.getByText(title)).toBeInTheDocument() + }) + + it('renders description correctly when provided', () => { + const title = 'Test Title' + const description = 'Test Description' + render() + expect(screen.getByText(description)).toBeInTheDocument() + }) + + it('does not render description when not provided', () => { + const title = 'Test Title' + render() + expect(screen.queryByText('Test Description')).not.toBeInTheDocument() + }) + + it('renders action correctly when provided', () => { + const title = 'Test Title' + const action = + render() + expect(screen.getByText('Test Action')).toBeInTheDocument() + }) + + it('does not render action when not provided', () => { + const title = 'Test Title' + render() + expect(screen.queryByText('Test Action')).not.toBeInTheDocument() + }) +}) diff --git a/web/containers/Brand/Logo/Mark.test.tsx b/web/containers/Brand/Logo/Mark.test.tsx new file mode 100644 index 0000000000..68df134c9a --- /dev/null +++ b/web/containers/Brand/Logo/Mark.test.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import LogoMark from './Mark' + +describe('LogoMark Component', () => { + it('renders with default width and height', () => { + render() + const image = screen.getByAltText('Jan - Logo') + expect(image).toBeInTheDocument() + expect(image).toHaveAttribute('width', '24') + expect(image).toHaveAttribute('height', '24') + }) + + it('renders with provided width and height', () => { + render() + const image = screen.getByAltText('Jan - Logo') + expect(image).toBeInTheDocument() + expect(image).toHaveAttribute('width', '48') + expect(image).toHaveAttribute('height', '48') + }) + + it('applies provided className', () => { + render() + const image = screen.getByAltText('Jan - Logo') + expect(image).toBeInTheDocument() + expect(image).toHaveClass('custom-class') + }) + + it('renders with the correct src and alt attributes', () => { + render() + const image = screen.getByAltText('Jan - Logo') + expect(image).toBeInTheDocument() + expect(image).toHaveAttribute('src', 'icons/app_icon.svg') + expect(image).toHaveAttribute('alt', 'Jan - Logo') + }) +}) diff --git a/web/containers/CenterPanelContainer/index.test.tsx b/web/containers/CenterPanelContainer/index.test.tsx new file mode 100644 index 0000000000..9e6fda0073 --- /dev/null +++ b/web/containers/CenterPanelContainer/index.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react' +import { useAtomValue } from 'jotai' +import CenterPanelContainer from './index' +import '@testing-library/jest-dom' + +// Mock useAtomValue from jotai +jest.mock('jotai', () => ({ + ...jest.requireActual('jotai'), + useAtomValue: jest.fn(), +})) + +describe('CenterPanelContainer', () => { + it('renders with reduceTransparent set to true', () => { + // Mock reduceTransparentAtom to be true + ;(useAtomValue as jest.Mock).mockReturnValue(true) + + render( + +
Test Child
+
+ ) + + // Check that the container renders with no border or rounded corners + const container = screen.getByText('Test Child').parentElement + expect(container).not.toHaveClass('rounded-lg border') + }) + + it('renders with reduceTransparent set to false', () => { + // Mock reduceTransparentAtom to be false + ;(useAtomValue as jest.Mock).mockReturnValue(false) + + render( + +
Test Child
+
+ ) + + // Check that the container renders with border and rounded corners + const container = screen.getByText('Test Child').parentElement + expect(container).toHaveClass('rounded-lg border') + }) + + it('renders children correctly', () => { + // Mock reduceTransparentAtom to be true for this test + ;(useAtomValue as jest.Mock).mockReturnValue(true) + + render( + +
Child Content
+
+ ) + + // Verify that the child content is rendered + expect(screen.getByText('Child Content')).toBeInTheDocument() + }) +}) diff --git a/web/containers/CopyInstruction/index.test.tsx b/web/containers/CopyInstruction/index.test.tsx new file mode 100644 index 0000000000..2f00e4e37c --- /dev/null +++ b/web/containers/CopyInstruction/index.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { useAtom } from 'jotai' +import '@testing-library/jest-dom' +import CopyOverInstruction from './index' + +// Mock the `useAtom` hook from jotai +jest.mock('jotai', () => ({ + useAtom: jest.fn(), +})) + +describe('CopyOverInstruction', () => { + const setCopyOverInstructionEnabled = jest.fn() + + beforeEach(() => { + ;(useAtom as jest.Mock).mockImplementation(() => [ + false, + setCopyOverInstructionEnabled, + ]) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should render the component with the switch in the correct state', () => { + render() + + // Assert the text is rendered + expect( + screen.getByText(/Save instructions for new threads/i) + ).toBeInTheDocument() + + // Assert the switch is rendered and in the unchecked state + const switchInput = screen.getByRole('checkbox') + expect(switchInput).toBeInTheDocument() + expect(switchInput).not.toBeChecked() + }) + + it('should call setCopyOverInstructionEnabled when the switch is toggled', () => { + render() + + const switchInput = screen.getByRole('checkbox') + + // Simulate toggling the switch + fireEvent.click(switchInput) + + // Assert that the atom setter is called with true when checked + expect(setCopyOverInstructionEnabled).toHaveBeenCalledWith(true) + }) + + it('should reflect the updated state when the atom value changes', () => { + // Mock the atom to return true (enabled state) + ;(useAtom as jest.Mock).mockImplementation(() => [ + true, + setCopyOverInstructionEnabled, + ]) + + render() + + const switchInput = screen.getByRole('checkbox') + + // The switch should now be checked + expect(switchInput).toBeChecked() + }) +}) diff --git a/web/containers/EngineSetting/index.test.tsx b/web/containers/EngineSetting/index.test.tsx new file mode 100644 index 0000000000..140a36395a --- /dev/null +++ b/web/containers/EngineSetting/index.test.tsx @@ -0,0 +1,115 @@ +import { render } from '@testing-library/react' +import '@testing-library/jest-dom' +import EngineSetting from './index' +import SettingComponentBuilder from '@/containers/ModelSetting/SettingComponent' +import { SettingComponentProps } from '@janhq/core' + +// Mock the SettingComponentBuilder component +jest.mock('@/containers/ModelSetting/SettingComponent', () => + jest.fn(() => null) +) + +describe('EngineSetting', () => { + const mockComponentData: SettingComponentProps[] = [ + { + key: 'setting1', + title: 'Setting 1', + description: 'This is the first setting.', + controllerType: 'input', + controllerProps: { + placeholder: 'Enter text', + value: 'default text', + type: 'text', + }, + }, + { + key: 'setting2', + title: 'Setting 2', + description: 'This is the second setting.', + controllerType: 'slider', + controllerProps: { + min: 0, + max: 100, + step: 1, + value: 50, + }, + }, + { + key: 'setting3', + title: 'Setting 3', + description: 'This is the third setting.', + controllerType: 'checkbox', + controllerProps: { + value: true, + }, + }, + ] + + const onValueChangedMock = jest.fn() + + afterEach(() => { + jest.clearAllMocks() // Clear mocks after each test + }) + + it('renders SettingComponentBuilder with the correct props', () => { + render( + + ) + + // Check that SettingComponentBuilder is called with the correct props + expect(SettingComponentBuilder).toHaveBeenCalledWith( + { + componentProps: mockComponentData, + disabled: false, + onValueUpdated: onValueChangedMock, + }, + {} + ) + }) + + it('renders SettingComponentBuilder with disabled prop', () => { + render( + + ) + + // Check that SettingComponentBuilder is called with disabled=true + expect(SettingComponentBuilder).toHaveBeenCalledWith( + { + componentProps: mockComponentData, + disabled: true, + onValueUpdated: onValueChangedMock, + }, + {} + ) + }) + + it('calls onValueChanged when the value is updated', () => { + // Simulating value update in SettingComponentBuilder + ;(SettingComponentBuilder as jest.Mock).mockImplementation( + ({ onValueUpdated }) => { + // Simulate calling the value update handler + onValueUpdated('setting1', 'new value') + return null + } + ) + + render( + + ) + + // Assert that onValueChanged is called with the correct parameters + expect(onValueChangedMock).toHaveBeenCalledWith('setting1', 'new value') + }) +}) diff --git a/web/containers/Loader/Bubble.tsx b/web/containers/Loader/Bubble.tsx deleted file mode 100644 index 0cd438ce0c..0000000000 --- a/web/containers/Loader/Bubble.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export default function BubbleLoader() { - return ( -
- - - -
- ) -} diff --git a/web/containers/Loader/ModelStart.test.tsx b/web/containers/Loader/ModelStart.test.tsx new file mode 100644 index 0000000000..62e333da05 --- /dev/null +++ b/web/containers/Loader/ModelStart.test.tsx @@ -0,0 +1,47 @@ +import '@testing-library/jest-dom' +import { render, screen, act } from '@testing-library/react' +import ModelStart from './ModelStart' // Adjust the path based on your file structure +import { useActiveModel } from '@/hooks/useActiveModel' + +// Mock the useActiveModel hook +jest.mock('@/hooks/useActiveModel', () => ({ + useActiveModel: jest.fn(), +})) + +describe('ModelStart', () => { + const mockSetStateModel = jest.fn() + const mockModel = { id: 'test-model' } + + beforeEach(() => { + // Reset the mock implementation before each test + jest.clearAllMocks() + }) + + it('renders correctly when loading is false', () => { + ;(useActiveModel as jest.Mock).mockReturnValue({ + stateModel: { + loading: false, + state: 'start', + model: mockModel, + }, + }) + + render() + // Ensure the component returns null when not loading + expect(screen.queryByText(/Starting model/i)).toBeNull() + }) + + it('renders loading state with model id', () => { + ;(useActiveModel as jest.Mock).mockReturnValue({ + stateModel: { + loading: true, + state: 'start', + model: mockModel, + }, + }) + + render() + // Ensure the loading text is rendered + expect(screen.getByText(/Starting model test-model/i)).toBeInTheDocument() + }) +}) diff --git a/web/containers/LoadingModal/index.test.tsx b/web/containers/LoadingModal/index.test.tsx new file mode 100644 index 0000000000..f5b17234d8 --- /dev/null +++ b/web/containers/LoadingModal/index.test.tsx @@ -0,0 +1,47 @@ +import '@testing-library/jest-dom' +import { render } from '@testing-library/react' +import { useAtomValue } from 'jotai' +import ResettingModal from './index' + +// Mocking the Jotai atom +jest.mock('jotai', () => { + const originalModule = jest.requireActual('jotai') + + return { + ...originalModule, + useAtomValue: jest.fn(), + } +}) + +describe('ResettingModal', () => { + it('renders the modal with loading info when provided', () => { + const mockLoadingInfo = { + title: 'Loading...', + message: 'Please wait while we process your request.', + } + + // Mock the useAtomValue hook to return mock loading info + ;(useAtomValue as jest.Mock).mockReturnValue(mockLoadingInfo) + + const { getByText } = render() + + // Check if the modal title and message are displayed + expect(getByText('Loading...')).toBeInTheDocument() + expect( + getByText('Please wait while we process your request.') + ).toBeInTheDocument() + }) + + it('does not render the modal when loading info is undefined', () => { + // Mock the useAtomValue hook to return undefined + ;(useAtomValue as jest.Mock).mockReturnValue(undefined) + + const { queryByText } = render() + + // Check that the modal does not appear + expect(queryByText('Loading...')).not.toBeInTheDocument() + expect( + queryByText('Please wait while we process your request.') + ).not.toBeInTheDocument() + }) +}) diff --git a/web/containers/MainViewContainer/index.test.tsx b/web/containers/MainViewContainer/index.test.tsx new file mode 100644 index 0000000000..bcafa92afb --- /dev/null +++ b/web/containers/MainViewContainer/index.test.tsx @@ -0,0 +1,56 @@ +import '@testing-library/jest-dom' + +import { render } from '@testing-library/react' +import { useAtomValue } from 'jotai' +import MainViewContainer from './index' +import { MainViewState } from '@/constants/screens' + +// Mocking the Jotai atom +jest.mock('jotai', () => { + const originalModule = jest.requireActual('jotai') + + return { + ...originalModule, + useAtomValue: jest.fn(), + } +}) + +// Mocking the screen components +jest.mock('@/screens/Hub', () => () =>
Hub Screen
) +jest.mock('@/screens/LocalServer', () => () =>
Local Server Screen
) +jest.mock('@/screens/Settings', () => () =>
Settings Screen
) +jest.mock('@/screens/Thread', () => () =>
Thread Screen
) + +describe('MainViewContainer', () => { + it('renders HubScreen when mainViewState is Hub', () => { + ;(useAtomValue as jest.Mock).mockReturnValue(MainViewState.Hub) + + const { getByText } = render() + + expect(getByText('Hub Screen')).toBeInTheDocument() + }) + + it('renders SettingsScreen when mainViewState is Settings', () => { + ;(useAtomValue as jest.Mock).mockReturnValue(MainViewState.Settings) + + const { getByText } = render() + + expect(getByText('Settings Screen')).toBeInTheDocument() + }) + + it('renders LocalServerScreen when mainViewState is LocalServer', () => { + ;(useAtomValue as jest.Mock).mockReturnValue(MainViewState.LocalServer) + + const { getByText } = render() + + expect(getByText('Local Server Screen')).toBeInTheDocument() + }) + + it('renders ThreadScreen when mainViewState is not defined', () => { + ;(useAtomValue as jest.Mock).mockReturnValue(undefined) + + const { getByText } = render() + + expect(getByText('Thread Screen')).toBeInTheDocument() + }) +}) diff --git a/web/containers/ModelConfigInput/index.test.tsx b/web/containers/ModelConfigInput/index.test.tsx new file mode 100644 index 0000000000..b92bdfcb23 --- /dev/null +++ b/web/containers/ModelConfigInput/index.test.tsx @@ -0,0 +1,85 @@ +import '@testing-library/jest-dom' +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import ModelConfigInput from './index' +import { Tooltip } from '@janhq/joi' + +// Mocking the Tooltip component to simplify testing +jest.mock('@janhq/joi', () => ({ + ...jest.requireActual('@janhq/joi'), + Tooltip: ({ + trigger, + content, + }: { + trigger: React.ReactNode + content: string + }) => ( +
+ {trigger} + {content} +
+ ), +})) + +describe('ModelConfigInput', () => { + it('renders correctly with given props', () => { + const { getByText, getByPlaceholderText } = render( + + ) + + // Check if title is rendered + expect(getByText('Test Title')).toBeInTheDocument() + + // Check if the description tooltip content is rendered + expect(getByText('This is a description.')).toBeInTheDocument() + + // Check if the placeholder is rendered + expect(getByPlaceholderText('Enter text here')).toBeInTheDocument() + }) + + it('calls onValueChanged when value changes', () => { + const onValueChangedMock = jest.fn() + const { getByPlaceholderText } = render( + + ) + + const textArea = getByPlaceholderText('Enter text here') + + // Simulate typing in the textarea + fireEvent.change(textArea, { target: { value: 'New Value' } }) + + // Check if onValueChanged was called with the new value + expect(onValueChangedMock).toHaveBeenCalledWith('New Value') + }) + + it('disables the textarea when disabled prop is true', () => { + const { getByPlaceholderText } = render( + + ) + + const textArea = getByPlaceholderText('Enter text here') + + // Check if the textarea is disabled + expect(textArea).toBeDisabled() + }) +}) diff --git a/web/containers/Providers/Responsive.test.tsx b/web/containers/Providers/Responsive.test.tsx new file mode 100644 index 0000000000..e72a5e7e62 --- /dev/null +++ b/web/containers/Providers/Responsive.test.tsx @@ -0,0 +1,110 @@ +import '@testing-library/jest-dom' +import React from 'react' +import { render } from '@testing-library/react' +import { useAtom } from 'jotai' +import Responsive from './Responsive' +import { showLeftPanelAtom, showRightPanelAtom } from '@/helpers/atoms/App.atom' + +// Mocking the required atoms +jest.mock('jotai', () => { + const originalModule = jest.requireActual('jotai') + return { + ...originalModule, + useAtom: jest.fn(), + useAtomValue: jest.fn(), + } +}) + +const mockSetShowLeftPanel = jest.fn() +const mockSetShowRightPanel = jest.fn() +const mockShowLeftPanel = true +const mockShowRightPanel = true + +beforeEach(() => { + // Mocking the atom behavior + ;(useAtom as jest.Mock).mockImplementation((atom) => { + if (atom === showLeftPanelAtom) { + return [mockShowLeftPanel, mockSetShowLeftPanel] + } + if (atom === showRightPanelAtom) { + return [mockShowRightPanel, mockSetShowRightPanel] + } + return [null, jest.fn()] + }) +}) + +describe('Responsive', () => { + beforeAll(() => { + // Mocking the window.matchMedia function + window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, // Set this to true to simulate mobile view + addListener: jest.fn(), + removeListener: jest.fn(), + } + }) + }) + + it('renders children correctly', () => { + const { getByText } = render( + +
Child Content
+
+ ) + + // Check if the child content is rendered + expect(getByText('Child Content')).toBeInTheDocument() + }) + + it('hides left and right panels on small screens', () => { + // Simulate mobile view + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: true, // Change to true to simulate mobile + addListener: jest.fn(), + removeListener: jest.fn(), + })) + + render( + +
Child Content
+
+ ) + + // Check that the left and right panel states were updated to false + expect(mockSetShowLeftPanel).toHaveBeenCalledWith(false) + expect(mockSetShowRightPanel).toHaveBeenCalledWith(false) + }) + + it('restores the last known panel states on larger screens', () => { + // Simulate mobile view first + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: true, // Change to true to simulate mobile + addListener: jest.fn(), + removeListener: jest.fn(), + })) + + render( + +
Child Content
+
+ ) + + // Change back to desktop view + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: false, // Change to false to simulate desktop + addListener: jest.fn(), + removeListener: jest.fn(), + })) + + // Call the effect manually to simulate the component re-rendering + const rerender = render( + +
Child Content
+
+ ) + + // Check that the last known states were restored (which were true initially) + expect(mockSetShowLeftPanel).toHaveBeenCalledWith(true) + expect(mockSetShowRightPanel).toHaveBeenCalledWith(true) + }) +}) diff --git a/web/containers/Providers/Theme.test.tsx b/web/containers/Providers/Theme.test.tsx new file mode 100644 index 0000000000..552bbecbe5 --- /dev/null +++ b/web/containers/Providers/Theme.test.tsx @@ -0,0 +1,24 @@ +import '@testing-library/jest-dom' +import React from 'react' +import { render } from '@testing-library/react' +import ThemeWrapper from './Theme' + +// Mock the ThemeProvider from next-themes +jest.mock('next-themes', () => ({ + ThemeProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +describe('ThemeWrapper', () => { + it('renders children within ThemeProvider', () => { + const { getByText } = render( + +
Child Component
+
+ ) + + // Check if the child component is rendered + expect(getByText('Child Component')).toBeInTheDocument() + }) +}) diff --git a/web/containers/RightPanelContainer/index.test.tsx b/web/containers/RightPanelContainer/index.test.tsx new file mode 100644 index 0000000000..4bb08913f5 --- /dev/null +++ b/web/containers/RightPanelContainer/index.test.tsx @@ -0,0 +1,126 @@ +import '@testing-library/jest-dom' + +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import RightPanelContainer from './index' +import { useAtom } from 'jotai' + +// Mocking ResizeObserver +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = ResizeObserver + +// Mocking window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}) + +// Mocking the required atoms +jest.mock('jotai', () => { + const originalModule = jest.requireActual('jotai') + return { + ...originalModule, + useAtom: jest.fn(), + useAtomValue: jest.fn(), + } +}) + +const mockSetShowRightPanel = jest.fn() +const mockShowRightPanel = true // Change this to test the panel visibility + +beforeEach(() => { + // Setting up the localStorage mock + localStorage.clear() + localStorage.setItem('rightPanelWidth', '280') // Setting a default width + + // Mocking the atom behavior + ;(useAtom as jest.Mock).mockImplementation(() => [ + mockShowRightPanel, + mockSetShowRightPanel, + ]) +}) + +describe('RightPanelContainer', () => { + it('renders correctly with children', () => { + const { getByText } = render( + +
Child Content
+
+ ) + + // Check if the child content is rendered + expect(getByText('Child Content')).toBeInTheDocument() + }) + + it('initializes width from localStorage', () => { + const { container } = render() + + // Check the width from localStorage is applied + const rightPanel = container.firstChild as HTMLDivElement + expect(rightPanel.style.width).toBe('280px') // Width from localStorage + }) + + it('changes width on resizing', () => { + const { container } = render() + + const rightPanel = container.firstChild as HTMLDivElement + + // Simulate mouse down on the resize handle + const resizeHandle = document.createElement('div') + resizeHandle.className = 'group/resize' + rightPanel.appendChild(resizeHandle) + + // Simulate mouse down to start resizing + fireEvent.mouseDown(resizeHandle) + + // Simulate mouse move event + fireEvent.mouseMove(window, { clientX: 100 }) + + // Simulate mouse up to stop resizing + fireEvent.mouseUp(window) + + // Verify that the right panel's width changes + // Since we can't get the actual width calculation in this test, + // you may want to check if the rightPanelWidth is updated in your implementation. + // Here, just check if the function is called: + expect(localStorage.getItem('rightPanelWidth')).toBeDefined() + }) + + it('hides panel when clicked outside on mobile', () => { + // Mock useMediaQuery to simulate mobile view + ;(window.matchMedia as jest.Mock).mockImplementation((query) => ({ + matches: true, // Always return true for mobile + addListener: jest.fn(), + removeListener: jest.fn(), + })) + + const { container } = render( + +
Child Content
+
+ ) + + const rightPanel = container.firstChild as HTMLDivElement + + // Simulate a click outside + fireEvent.mouseDown(document.body) + fireEvent.mouseUp(document.body) // Ensure the click event is completed + + // Verify that setShowRightPanel was called to hide the panel + expect(mockSetShowRightPanel).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/jest.config.js b/web/jest.config.js index 7d2bee9ee5..12ed39b203 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -19,8 +19,8 @@ const config = { runner: './testRunner.js', collectCoverageFrom: ['./**/*.{ts,tsx}'], transform: { - "^.+\\.tsx?$": [ - "ts-jest", + '^.+\\.tsx?$': [ + 'ts-jest', { diagnostics: false, },