diff --git a/.eslintrc.json b/.eslintrc.json index aa6cad9..8913533 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -10,11 +10,7 @@ "sourceType": "module", "project": "./tsconfig.json" }, - "plugins": [ - "@typescript-eslint", - "unused-imports", - "obsidianmd" - ], + "plugins": ["@typescript-eslint", "unused-imports", "obsidianmd"], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", @@ -69,15 +65,11 @@ "no-console": [ "error", { - "allow": [ - "warn", - "error", - "debug" - ] + "allow": ["warn", "error", "debug"] } ], "prefer-const": "error", "no-var": "error", "no-case-declarations": "off" } -} \ No newline at end of file +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92d3c72..e2da792 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,223 +1,223 @@ name: CI Pipeline on: - push: - branches: [main, develop] - tags: - - 'v*' - pull_request: - branches: [main, develop] - workflow_dispatch: + push: + branches: [main, develop] + tags: + - 'v*' + pull_request: + branches: [main, develop] + workflow_dispatch: env: - NODE_VERSION: '18' + NODE_VERSION: '18' jobs: - # 린트 및 타입 체크 - quality-check: - name: Code Quality Check - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - name: Install dependencies - run: npm ci - - - name: Run ESLint (Obsidian plugin review bot compatible) - run: | - echo "🔍 Running ESLint with Obsidian plugin review bot compatible rules..." - npm run lint 2>&1 | tee lint-output.txt || true - - # Check if there were any errors (exclude "0 errors" from matching) - if grep -E "[1-9][0-9]* error" lint-output.txt; then - echo "❌ ESLint found errors" - npm run lint - exit 1 - elif grep -q "warning" lint-output.txt; then - echo "⚠️ ESLint found warnings" - npm run lint - else - echo "✅ ESLint passed with no errors or warnings" - fi - - - name: Check formatting - run: npm run format:check - - - name: Type check - run: npm run typecheck + # 린트 및 타입 체크 + quality-check: + name: Code Quality Check + runs-on: ubuntu-latest - # 유닛 테스트 - unit-tests: - name: Unit Tests - runs-on: ubuntu-latest - needs: quality-check - - strategy: - matrix: - node-version: [18] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}-${{ matrix.node-version }} - restore-keys: | - ${{ runner.os }}-node-${{ matrix.node-version }}- - - - name: Install dependencies - run: npm ci - - - name: Run unit tests - run: npm run test:unit -- --coverage --passWithNoTests - continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 - # 통합 테스트 - integration-tests: - name: Integration Tests - runs-on: ubuntu-latest - needs: quality-check - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - name: Install dependencies - run: npm ci - - - name: Run integration tests - run: npm run test:integration -- --coverage --passWithNoTests - continue-on-error: true - env: - TEST_API_KEY: ${{ secrets.TEST_API_KEY }} - TEST_API_URL: ${{ secrets.TEST_API_URL }} + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' - # 빌드 테스트 - build: - name: Build Test - runs-on: ubuntu-latest - needs: quality-check - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - name: Install dependencies - run: npm ci - - - name: Build project - run: npm run build - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: build-artifacts - path: | - main.js - manifest.json - - - name: Check bundle size - run: | - echo "Checking bundle size..." - ls -lh main.js - size=$(stat -c%s main.js 2>/dev/null || stat -f%z main.js) - if [ $size -gt 5242880 ]; then - echo "Bundle size exceeds 5MB limit!" - exit 1 - fi - echo "Bundle size OK: $size bytes" + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- - # 최종 상태 체크 - 핵심 작업만 검증 - status-check: - name: Final Status Check - runs-on: ubuntu-latest - needs: [quality-check, unit-tests, integration-tests, build] - if: always() - - steps: - - name: Check job statuses - run: | - echo "Checking critical job statuses..." - - # Quality check is required - if [[ "${{ needs.quality-check.result }}" != "success" ]]; then - echo "❌ Quality check failed" - exit 1 - fi - echo "✅ Quality check passed" - - # Build is required - if [[ "${{ needs.build.result }}" != "success" ]]; then - echo "❌ Build failed" - exit 1 - fi - echo "✅ Build passed" - - # Unit/Integration tests - warn but don't fail - if [[ "${{ needs.unit-tests.result }}" != "success" ]]; then - echo "⚠️ Unit tests had issues (non-blocking)" - else - echo "✅ Unit tests passed" - fi - - if [[ "${{ needs.integration-tests.result }}" != "success" ]]; then - echo "⚠️ Integration tests had issues (non-blocking)" - else - echo "✅ Integration tests passed" - fi - - echo "" - echo "🎉 All critical jobs passed successfully!" + - name: Install dependencies + run: npm ci + + - name: Run ESLint (Obsidian plugin review bot compatible) + run: | + echo "🔍 Running ESLint with Obsidian plugin review bot compatible rules..." + npm run lint 2>&1 | tee lint-output.txt || true + + # Check if there were any errors (exclude "0 errors" from matching) + if grep -E "[1-9][0-9]* error" lint-output.txt; then + echo "❌ ESLint found errors" + npm run lint + exit 1 + elif grep -q "warning" lint-output.txt; then + echo "⚠️ ESLint found warnings" + npm run lint + else + echo "✅ ESLint passed with no errors or warnings" + fi + + - name: Check formatting + run: npm run format:check + + - name: Type check + run: npm run typecheck + + # 유닛 테스트 + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + needs: quality-check + + strategy: + matrix: + node-version: [18] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}-${{ matrix.node-version }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}- + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm run test:unit -- --coverage --passWithNoTests + continue-on-error: true + + # 통합 테스트 + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + needs: quality-check + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm ci + + - name: Run integration tests + run: npm run test:integration -- --coverage --passWithNoTests + continue-on-error: true + env: + TEST_API_KEY: ${{ secrets.TEST_API_KEY }} + TEST_API_URL: ${{ secrets.TEST_API_URL }} + + # 빌드 테스트 + build: + name: Build Test + runs-on: ubuntu-latest + needs: quality-check + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + main.js + manifest.json + + - name: Check bundle size + run: | + echo "Checking bundle size..." + ls -lh main.js + size=$(stat -c%s main.js 2>/dev/null || stat -f%z main.js) + if [ $size -gt 5242880 ]; then + echo "Bundle size exceeds 5MB limit!" + exit 1 + fi + echo "Bundle size OK: $size bytes" + + # 최종 상태 체크 - 핵심 작업만 검증 + status-check: + name: Final Status Check + runs-on: ubuntu-latest + needs: [quality-check, unit-tests, integration-tests, build] + if: always() + + steps: + - name: Check job statuses + run: | + echo "Checking critical job statuses..." + + # Quality check is required + if [[ "${{ needs.quality-check.result }}" != "success" ]]; then + echo "❌ Quality check failed" + exit 1 + fi + echo "✅ Quality check passed" + + # Build is required + if [[ "${{ needs.build.result }}" != "success" ]]; then + echo "❌ Build failed" + exit 1 + fi + echo "✅ Build passed" + + # Unit/Integration tests - warn but don't fail + if [[ "${{ needs.unit-tests.result }}" != "success" ]]; then + echo "⚠️ Unit tests had issues (non-blocking)" + else + echo "✅ Unit tests passed" + fi + + if [[ "${{ needs.integration-tests.result }}" != "success" ]]; then + echo "⚠️ Integration tests had issues (non-blocking)" + else + echo "✅ Integration tests passed" + fi + + echo "" + echo "🎉 All critical jobs passed successfully!" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267..b3b6859 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -1,44 +1,44 @@ name: Claude Code on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. # prompt: 'Update the pull request description to include a summary of changes.' @@ -48,3 +48,7 @@ jobs: # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b907a54..e16c83a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,222 +1,222 @@ name: Release Pipeline on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - version: - description: 'Release version (e.g., v3.1.0)' - required: true - type: string + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g., v3.1.0)' + required: true + type: string permissions: - contents: write - packages: write + contents: write + packages: write jobs: - # 버전 검증 - validate-release: - name: Validate Release - runs-on: ubuntu-latest - outputs: - version: ${{ steps.get-version.outputs.version }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Get version from tag or input - id: get-version - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - VERSION="${{ github.event.inputs.version }}" - else - VERSION="${GITHUB_REF#refs/tags/}" - fi - - echo "Version: $VERSION" - - # Validate version format (v0.0.0) - if ! [[ $VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then - echo "❌ Invalid version format: $VERSION" - echo "Expected format: v0.0.0 or v0.0.0-beta" - exit 1 - fi - - echo "✅ Version format valid: $VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT + # 버전 검증 + validate-release: + name: Validate Release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.get-version.outputs.version }} - # CI에서 빌드한 artifacts 다운로드 (CI 성공 확인) - download-ci-artifacts: - name: Download CI Artifacts - runs-on: ubuntu-latest - needs: validate-release - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ needs.validate-release.outputs.version }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build production bundle - run: npm run build + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version from tag or input + id: get-version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + + echo "Version: $VERSION" + + # Validate version format (v0.0.0) + if ! [[ $VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then + echo "❌ Invalid version format: $VERSION" + echo "Expected format: v0.0.0 or v0.0.0-beta" + exit 1 + fi + + echo "✅ Version format valid: $VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # CI에서 빌드한 artifacts 다운로드 (CI 성공 확인) + download-ci-artifacts: + name: Download CI Artifacts + runs-on: ubuntu-latest + needs: validate-release + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ needs.validate-release.outputs.version }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build production bundle + run: npm run build + env: + NODE_ENV: production + + - name: Verify required files + run: | + if [ ! -f main.js ]; then + echo "❌ main.js not found" + exit 1 + fi + if [ ! -f manifest.json ]; then + echo "❌ manifest.json not found" + exit 1 + fi + echo "✅ All required files present" + + - name: Create release archive + run: | + mkdir -p release + cp main.js manifest.json README.md release/ + if [ -f LICENSE ]; then cp LICENSE release/; fi + if [ -f styles.css ]; then cp styles.css release/; fi + + VERSION="${{ needs.validate-release.outputs.version }}" + zip -r "obsidian-speech-to-text-${VERSION}.zip" release/ + + - name: Upload release artifacts + uses: actions/upload-artifact@v4 + with: + name: release-artifacts + path: | + obsidian-speech-to-text-*.zip + main.js + manifest.json + styles.css + retention-days: 7 + + # 릴리즈 노트 생성 + generate-release-notes: + name: Generate Release Notes + runs-on: ubuntu-latest + needs: validate-release + outputs: + release_notes: ${{ steps.notes.outputs.notes }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ needs.validate-release.outputs.version }} + + - name: Generate changelog + id: notes + run: | + VERSION="${{ needs.validate-release.outputs.version }}" + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + + echo "## What's Changed" > release-notes.md + echo "" >> release-notes.md + + if [[ -n "$PREVIOUS_TAG" ]]; then + echo "### 🚀 Features" >> release-notes.md + git log ${PREVIOUS_TAG}..HEAD --grep="feat:" --pretty="- %s (%h)" >> release-notes.md || echo "- No new features" >> release-notes.md + + echo "" >> release-notes.md + echo "### 🐛 Bug Fixes" >> release-notes.md + git log ${PREVIOUS_TAG}..HEAD --grep="fix:" --pretty="- %s (%h)" >> release-notes.md || echo "- No bug fixes" >> release-notes.md + + echo "" >> release-notes.md + echo "### 🔧 Maintenance" >> release-notes.md + git log ${PREVIOUS_TAG}..HEAD --grep="chore:" --pretty="- %s (%h)" >> release-notes.md || echo "- No maintenance updates" >> release-notes.md + + echo "" >> release-notes.md + echo "### 📝 Documentation" >> release-notes.md + git log ${PREVIOUS_TAG}..HEAD --grep="docs:" --pretty="- %s (%h)" >> release-notes.md || echo "- No documentation changes" >> release-notes.md + else + echo "🎉 Initial release" >> release-notes.md + fi + + echo "" >> release-notes.md + if [[ -n "$PREVIOUS_TAG" ]]; then + echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${VERSION}" >> release-notes.md + fi + + { + echo 'notes<> $GITHUB_OUTPUT + + # GitHub 릴리스 생성 + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [validate-release, download-ci-artifacts, generate-release-notes] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: release-artifacts + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ needs.validate-release.outputs.version }} + name: Release ${{ needs.validate-release.outputs.version }} + body: ${{ needs.generate-release-notes.outputs.release_notes }} + draft: false + prerelease: ${{ contains(needs.validate-release.outputs.version, '-') }} + files: | + obsidian-speech-to-text-*.zip + main.js + manifest.json + styles.css + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # 알림 발송 + notify-release: + name: Send Release Notifications + runs-on: ubuntu-latest + needs: [validate-release, create-release] + if: always() env: - NODE_ENV: production - - - name: Verify required files - run: | - if [ ! -f main.js ]; then - echo "❌ main.js not found" - exit 1 - fi - if [ ! -f manifest.json ]; then - echo "❌ manifest.json not found" - exit 1 - fi - echo "✅ All required files present" - - - name: Create release archive - run: | - mkdir -p release - cp main.js manifest.json README.md release/ - if [ -f LICENSE ]; then cp LICENSE release/; fi - if [ -f styles.css ]; then cp styles.css release/; fi - - VERSION="${{ needs.validate-release.outputs.version }}" - zip -r "obsidian-speech-to-text-${VERSION}.zip" release/ - - - name: Upload release artifacts - uses: actions/upload-artifact@v4 - with: - name: release-artifacts - path: | - obsidian-speech-to-text-*.zip - main.js - manifest.json - styles.css - retention-days: 7 + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - # 릴리즈 노트 생성 - generate-release-notes: - name: Generate Release Notes - runs-on: ubuntu-latest - needs: validate-release - outputs: - release_notes: ${{ steps.notes.outputs.notes }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ needs.validate-release.outputs.version }} - - - name: Generate changelog - id: notes - run: | - VERSION="${{ needs.validate-release.outputs.version }}" - PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - - echo "## What's Changed" > release-notes.md - echo "" >> release-notes.md - - if [[ -n "$PREVIOUS_TAG" ]]; then - echo "### 🚀 Features" >> release-notes.md - git log ${PREVIOUS_TAG}..HEAD --grep="feat:" --pretty="- %s (%h)" >> release-notes.md || echo "- No new features" >> release-notes.md - - echo "" >> release-notes.md - echo "### 🐛 Bug Fixes" >> release-notes.md - git log ${PREVIOUS_TAG}..HEAD --grep="fix:" --pretty="- %s (%h)" >> release-notes.md || echo "- No bug fixes" >> release-notes.md - - echo "" >> release-notes.md - echo "### 🔧 Maintenance" >> release-notes.md - git log ${PREVIOUS_TAG}..HEAD --grep="chore:" --pretty="- %s (%h)" >> release-notes.md || echo "- No maintenance updates" >> release-notes.md - - echo "" >> release-notes.md - echo "### 📝 Documentation" >> release-notes.md - git log ${PREVIOUS_TAG}..HEAD --grep="docs:" --pretty="- %s (%h)" >> release-notes.md || echo "- No documentation changes" >> release-notes.md - else - echo "🎉 Initial release" >> release-notes.md - fi - - echo "" >> release-notes.md - if [[ -n "$PREVIOUS_TAG" ]]; then - echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${VERSION}" >> release-notes.md - fi - - { - echo 'notes<> $GITHUB_OUTPUT + steps: + - name: Send Discord notification + if: ${{ needs.create-release.result == 'success' && env.DISCORD_WEBHOOK != '' }} + run: | + VERSION="${{ needs.validate-release.outputs.version }}" + curl -H "Content-Type: application/json" \ + -X POST \ + -d "{\"content\":\"🎉 **Speech to Text ${VERSION} Released!**\\n\\nCheck out the release: https://github.com/${{ github.repository }}/releases/tag/${VERSION}\"}" \ + $DISCORD_WEBHOOK - # GitHub 릴리스 생성 - create-release: - name: Create GitHub Release - runs-on: ubuntu-latest - needs: [validate-release, download-ci-artifacts, generate-release-notes] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Download release artifacts - uses: actions/download-artifact@v4 - with: - name: release-artifacts - - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - tag_name: ${{ needs.validate-release.outputs.version }} - name: Release ${{ needs.validate-release.outputs.version }} - body: ${{ needs.generate-release-notes.outputs.release_notes }} - draft: false - prerelease: ${{ contains(needs.validate-release.outputs.version, '-') }} - files: | - obsidian-speech-to-text-*.zip - main.js - manifest.json - styles.css - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # 알림 발송 - notify-release: - name: Send Release Notifications - runs-on: ubuntu-latest - needs: [validate-release, create-release] - if: always() - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - - steps: - - name: Send Discord notification - if: ${{ needs.create-release.result == 'success' && env.DISCORD_WEBHOOK != '' }} - run: | - VERSION="${{ needs.validate-release.outputs.version }}" - curl -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\":\"🎉 **Speech to Text ${VERSION} Released!**\\n\\nCheck out the release: https://github.com/${{ github.repository }}/releases/tag/${VERSION}\"}" \ - $DISCORD_WEBHOOK - - - name: Send failure notification - if: ${{ needs.create-release.result == 'failure' && env.DISCORD_WEBHOOK != '' }} - run: | - curl -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\":\"❌ **Release ${{ needs.validate-release.outputs.version }} Failed!**\\n\\nCheck the workflow: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \ - $DISCORD_WEBHOOK + - name: Send failure notification + if: ${{ needs.create-release.result == 'failure' && env.DISCORD_WEBHOOK != '' }} + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d "{\"content\":\"❌ **Release ${{ needs.validate-release.outputs.version }} Failed!**\\n\\nCheck the workflow: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \ + $DISCORD_WEBHOOK diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 6274b1d..a297a3d 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -1,173 +1,173 @@ name: Version Bump on: - pull_request: - types: [closed] - branches: [main] - workflow_dispatch: - inputs: - bump_type: - description: 'Version bump type' - required: true - type: choice - options: - - patch - - minor - - major - custom_version: - description: 'Custom version (e.g., 3.1.0, overrides bump_type)' - required: false - type: string + pull_request: + types: [closed] + branches: [main] + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + custom_version: + description: 'Custom version (e.g., 3.1.0, overrides bump_type)' + required: false + type: string permissions: - contents: write + contents: write jobs: - bump-version: - name: Bump Version - runs-on: ubuntu-latest - # PR이 merge되었고 특정 라벨이 있거나, workflow_dispatch인 경우에만 실행 - if: | - (github.event.pull_request.merged == true && - (contains(github.event.pull_request.labels.*.name, 'bump:patch') || - contains(github.event.pull_request.labels.*.name, 'bump:minor') || - contains(github.event.pull_request.labels.*.name, 'bump:major'))) || - github.event_name == 'workflow_dispatch' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - - - name: Configure Git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Determine version bump type - id: bump-type - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - if [[ -n "${{ github.event.inputs.custom_version }}" ]]; then - echo "bump_type=custom" >> $GITHUB_OUTPUT - echo "custom_version=${{ github.event.inputs.custom_version }}" >> $GITHUB_OUTPUT - else - echo "bump_type=${{ github.event.inputs.bump_type }}" >> $GITHUB_OUTPUT - fi - else - # PR 라벨에서 bump type 결정 - if [[ "${{ contains(github.event.pull_request.labels.*.name, 'bump:major') }}" == "true" ]]; then - echo "bump_type=major" >> $GITHUB_OUTPUT - elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'bump:minor') }}" == "true" ]]; then - echo "bump_type=minor" >> $GITHUB_OUTPUT - else - echo "bump_type=patch" >> $GITHUB_OUTPUT - fi - fi - - - name: Get current version - id: current-version - run: | - CURRENT_VERSION=$(node -p "require('./manifest.json').version") - echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT - echo "Current version: $CURRENT_VERSION" - - - name: Calculate new version - id: new-version - run: | - CURRENT="${{ steps.current-version.outputs.current }}" - BUMP_TYPE="${{ steps.bump-type.outputs.bump_type }}" - - if [[ "$BUMP_TYPE" == "custom" ]]; then - NEW_VERSION="${{ steps.bump-type.outputs.custom_version }}" - else - # 현재 버전을 major.minor.patch로 분리 - IFS='.' read -r -a parts <<< "$CURRENT" - major="${parts[0]}" - minor="${parts[1]}" - patch="${parts[2]}" - - case "$BUMP_TYPE" in - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - patch) - patch=$((patch + 1)) - ;; - esac - - NEW_VERSION="${major}.${minor}.${patch}" - fi - - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT - echo "📦 New version: $NEW_VERSION (from $CURRENT via $BUMP_TYPE)" - - - name: Update version files - run: | - NEW_VERSION="${{ steps.new-version.outputs.version }}" - - # manifest.json 업데이트 - node -e " - const fs = require('fs'); - const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')); - manifest.version = '$NEW_VERSION'; - fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, '\\t') + '\\n'); - " - - # package.json 업데이트 - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.version = '$NEW_VERSION'; - fs.writeFileSync('package.json', JSON.stringify(pkg, null, '\\t') + '\\n'); - " - - # versions.json 업데이트 - MIN_APP_VERSION=$(node -p "require('./manifest.json').minAppVersion") - node -e " - const fs = require('fs'); - const versions = JSON.parse(fs.readFileSync('versions.json', 'utf8')); - versions['$NEW_VERSION'] = '$MIN_APP_VERSION'; - fs.writeFileSync('versions.json', JSON.stringify(versions, null, '\\t') + '\\n'); - " - - echo "✅ Updated manifest.json, package.json, and versions.json" - - - name: Commit version bump - run: | - NEW_VERSION="${{ steps.new-version.outputs.version }}" - git add manifest.json package.json versions.json - git commit -m "chore: bump version to $NEW_VERSION" - git push origin main - - - name: Create and push tag - run: | - TAG="${{ steps.new-version.outputs.tag }}" - git tag -a "$TAG" -m "Release $TAG" - git push origin "$TAG" - echo "✅ Created and pushed tag: $TAG" - - - name: Summary - run: | - echo "## 🎉 Version Bump Complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Previous version**: ${{ steps.current-version.outputs.current }}" >> $GITHUB_STEP_SUMMARY - echo "- **New version**: ${{ steps.new-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "- **Tag**: ${{ steps.new-version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY - echo "- **Bump type**: ${{ steps.bump-type.outputs.bump_type }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The tag has been created and pushed. Release workflow will trigger automatically." >> $GITHUB_STEP_SUMMARY + bump-version: + name: Bump Version + runs-on: ubuntu-latest + # PR이 merge되었고 특정 라벨이 있거나, workflow_dispatch인 경우에만 실행 + if: | + (github.event.pull_request.merged == true && + (contains(github.event.pull_request.labels.*.name, 'bump:patch') || + contains(github.event.pull_request.labels.*.name, 'bump:minor') || + contains(github.event.pull_request.labels.*.name, 'bump:major'))) || + github.event_name == 'workflow_dispatch' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Determine version bump type + id: bump-type + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + if [[ -n "${{ github.event.inputs.custom_version }}" ]]; then + echo "bump_type=custom" >> $GITHUB_OUTPUT + echo "custom_version=${{ github.event.inputs.custom_version }}" >> $GITHUB_OUTPUT + else + echo "bump_type=${{ github.event.inputs.bump_type }}" >> $GITHUB_OUTPUT + fi + else + # PR 라벨에서 bump type 결정 + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'bump:major') }}" == "true" ]]; then + echo "bump_type=major" >> $GITHUB_OUTPUT + elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'bump:minor') }}" == "true" ]]; then + echo "bump_type=minor" >> $GITHUB_OUTPUT + else + echo "bump_type=patch" >> $GITHUB_OUTPUT + fi + fi + + - name: Get current version + id: current-version + run: | + CURRENT_VERSION=$(node -p "require('./manifest.json').version") + echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "Current version: $CURRENT_VERSION" + + - name: Calculate new version + id: new-version + run: | + CURRENT="${{ steps.current-version.outputs.current }}" + BUMP_TYPE="${{ steps.bump-type.outputs.bump_type }}" + + if [[ "$BUMP_TYPE" == "custom" ]]; then + NEW_VERSION="${{ steps.bump-type.outputs.custom_version }}" + else + # 현재 버전을 major.minor.patch로 분리 + IFS='.' read -r -a parts <<< "$CURRENT" + major="${parts[0]}" + minor="${parts[1]}" + patch="${parts[2]}" + + case "$BUMP_TYPE" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; + esac + + NEW_VERSION="${major}.${minor}.${patch}" + fi + + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + echo "📦 New version: $NEW_VERSION (from $CURRENT via $BUMP_TYPE)" + + - name: Update version files + run: | + NEW_VERSION="${{ steps.new-version.outputs.version }}" + + # manifest.json 업데이트 + node -e " + const fs = require('fs'); + const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')); + manifest.version = '$NEW_VERSION'; + fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, '\\t') + '\\n'); + " + + # package.json 업데이트 + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '$NEW_VERSION'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, '\\t') + '\\n'); + " + + # versions.json 업데이트 + MIN_APP_VERSION=$(node -p "require('./manifest.json').minAppVersion") + node -e " + const fs = require('fs'); + const versions = JSON.parse(fs.readFileSync('versions.json', 'utf8')); + versions['$NEW_VERSION'] = '$MIN_APP_VERSION'; + fs.writeFileSync('versions.json', JSON.stringify(versions, null, '\\t') + '\\n'); + " + + echo "✅ Updated manifest.json, package.json, and versions.json" + + - name: Commit version bump + run: | + NEW_VERSION="${{ steps.new-version.outputs.version }}" + git add manifest.json package.json versions.json + git commit -m "chore: bump version to $NEW_VERSION" + git push origin main + + - name: Create and push tag + run: | + TAG="${{ steps.new-version.outputs.tag }}" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "✅ Created and pushed tag: $TAG" + + - name: Summary + run: | + echo "## 🎉 Version Bump Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Previous version**: ${{ steps.current-version.outputs.current }}" >> $GITHUB_STEP_SUMMARY + echo "- **New version**: ${{ steps.new-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **Tag**: ${{ steps.new-version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY + echo "- **Bump type**: ${{ steps.bump-type.outputs.bump_type }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The tag has been created and pushed. Release workflow will trigger automatically." >> $GITHUB_STEP_SUMMARY diff --git a/.prettierrc b/.prettierrc index f63b428..b1906d3 100644 --- a/.prettierrc +++ b/.prettierrc @@ -14,4 +14,4 @@ "proseWrap": "preserve", "htmlWhitespaceSensitivity": "css", "embeddedLanguageFormatting": "auto" -} \ No newline at end of file +} diff --git a/CLAUDE.md b/CLAUDE.md index 197266f..8a38673 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ ## Project Work Principles -- If you have existing code, first check if it's workable by modifying it. -- Always find and update related documentation after code modifications are complete. -- Actively collaborate with other agents. +- If you have existing code, first check if it's workable by modifying it. +- Always find and update related documentation after code modifications are complete. +- Actively collaborate with other agents. ## Code and Script Work Principles diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34827fa..8965814 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,25 +12,25 @@ This project and everyone participating in it is governed by our [Code of Conduc Before creating bug reports, please check the existing issues to avoid duplicates. When you create a bug report, include as many details as possible: -- **Use a clear and descriptive title** -- **Describe the exact steps to reproduce the problem** -- **Provide specific examples** (audio file format, size, etc.) -- **Describe the behavior you observed and what you expected** -- **Include screenshots or error messages** if applicable -- **Specify your environment:** - - Obsidian version - - Operating system and version - - Plugin version - - Provider (OpenAI Whisper/Deepgram) +- **Use a clear and descriptive title** +- **Describe the exact steps to reproduce the problem** +- **Provide specific examples** (audio file format, size, etc.) +- **Describe the behavior you observed and what you expected** +- **Include screenshots or error messages** if applicable +- **Specify your environment:** + - Obsidian version + - Operating system and version + - Plugin version + - Provider (OpenAI Whisper/Deepgram) ### Suggesting Enhancements Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion: -- **Use a clear and descriptive title** -- **Provide a detailed description of the suggested enhancement** -- **Explain why this enhancement would be useful** -- **List some examples of how it would be used** +- **Use a clear and descriptive title** +- **Provide a detailed description of the suggested enhancement** +- **Explain why this enhancement would be useful** +- **List some examples of how it would be used** ### Pull Requests @@ -43,12 +43,12 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme 7. **Run linter**: `npm run lint` 8. **Ensure type safety**: `npm run typecheck` 9. **Commit with conventional commit format**: - - `feat:` for new features - - `fix:` for bug fixes - - `docs:` for documentation - - `refactor:` for code refactoring - - `test:` for tests - - `chore:` for maintenance + - `feat:` for new features + - `fix:` for bug fixes + - `docs:` for documentation + - `refactor:` for code refactoring + - `test:` for tests + - `chore:` for maintenance ## Development Setup @@ -90,31 +90,31 @@ src/ ### TypeScript -- Use TypeScript strict mode -- Prefer interfaces over types for object shapes -- Use explicit return types for public methods -- Document complex logic with comments +- Use TypeScript strict mode +- Prefer interfaces over types for object shapes +- Use explicit return types for public methods +- Document complex logic with comments ### Code Style -- Follow the existing code style -- Use ESLint and Prettier (configured in the project) -- Keep functions small and focused -- Use meaningful variable and function names +- Follow the existing code style +- Use ESLint and Prettier (configured in the project) +- Keep functions small and focused +- Use meaningful variable and function names ### Architecture -- Follow Clean Architecture principles -- Maintain separation of concerns -- Use dependency injection where appropriate -- Keep business logic in the core layer +- Follow Clean Architecture principles +- Maintain separation of concerns +- Use dependency injection where appropriate +- Keep business logic in the core layer ### Testing -- Write unit tests for business logic -- Write integration tests for API interactions -- Maintain test coverage above 70% -- Use descriptive test names +- Write unit tests for business logic +- Write integration tests for API interactions +- Maintain test coverage above 70% +- Use descriptive test names ## API Keys for Testing @@ -139,13 +139,13 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): ### Types -- `feat`: New feature -- `fix`: Bug fix -- `docs`: Documentation changes -- `style`: Code style changes (formatting) -- `refactor`: Code refactoring -- `test`: Adding or updating tests -- `chore`: Maintenance tasks +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `style`: Code style changes (formatting) +- `refactor`: Code refactoring +- `test`: Adding or updating tests +- `chore`: Maintenance tasks ### Examples @@ -187,16 +187,16 @@ Releases are managed by maintainers: Feel free to: -- Open a [Discussion](https://github.com/asyouplz/SpeechNote/discussions) for questions -- Join our community discussions -- Reach out to maintainers +- Open a [Discussion](https://github.com/asyouplz/SpeechNote/discussions) for questions +- Join our community discussions +- Reach out to maintainers ## Recognition Contributors are recognized in: -- Release notes -- Credits section of README -- GitHub contributors page +- Release notes +- Credits section of README +- GitHub contributors page -Thank you for contributing! 🎉 \ No newline at end of file +Thank you for contributing! 🎉 diff --git a/README.md b/README.md index 7962e72..1a330f4 100644 --- a/README.md +++ b/README.md @@ -21,51 +21,57 @@ Convert audio recordings to text directly in Obsidian using multiple AI provider ## Features ### 🎙️ Multi-Provider Audio Transcription -- **OpenAI Whisper**: High accuracy, stable performance -- **Deepgram Nova 3**: Latest model with 98% accuracy, 70% cost reduction -- **Speaker Diarization**: Automatic speaker separation with "Speaker 1:", "Speaker 2:" format -- **Auto Selection**: Automatically chooses the best provider for each file -- **Supported Formats**: M4A, MP3, WAV, MP4, WebM, OGG, FLAC + +- **OpenAI Whisper**: High accuracy, stable performance +- **Deepgram Nova 3**: Latest model with 98% accuracy, 70% cost reduction +- **Speaker Diarization**: Automatic speaker separation with "Speaker 1:", "Speaker 2:" format +- **Auto Selection**: Automatically chooses the best provider for each file +- **Supported Formats**: M4A, MP3, WAV, MP4, WebM, OGG, FLAC ### 🌐 Multi-language Support -- **Auto Detection**: Automatic language recognition -- **40+ Languages**: Korean, English, Japanese, Chinese, Spanish, French, German, etc. -- **Provider Optimization**: Each provider optimized for different languages + +- **Auto Detection**: Automatic language recognition +- **40+ Languages**: Korean, English, Japanese, Chinese, Spanish, French, German, etc. +- **Provider Optimization**: Each provider optimized for different languages ### 📝 Smart Text Insertion & Speaker Recognition -- **Cursor Position**: Insert at current cursor location -- **Note Positions**: Beginning or end of note -- **Auto Note Creation**: Creates new note if no active editor -- **Speaker Diarization**: Automatic speaker identification and labeling -- **Multi-Speaker Support**: Clear separation for meetings, interviews, conversations + +- **Cursor Position**: Insert at current cursor location +- **Note Positions**: Beginning or end of note +- **Auto Note Creation**: Creates new note if no active editor +- **Speaker Diarization**: Automatic speaker identification and labeling +- **Multi-Speaker Support**: Clear separation for meetings, interviews, conversations ### ⚡ Performance & Architecture -- **Nova-3 Model**: 98% accuracy with $0.0043/min (70% cost reduction) -- **Clean Architecture**: Domain-driven design with clear separation of concerns -- **Intelligent Provider Selection**: Best provider based on file size and format -- **Real-time Progress**: Status bar progress indicator with cancellation support -- **Async Processing**: Non-blocking background processing -- **Memory Management**: Built-in memory monitoring and optimization -- **Performance Benchmarking**: Integrated performance monitoring tools -- **Error Boundaries**: Comprehensive error handling and recovery -- **Dependency Injection**: IoC container for better testability -- **Event-Driven Architecture**: Decoupled components with EventManager -- **Batch Processing**: Efficient batch request handling -- **Caching Layer**: Smart caching for improved performance -- **Settings Migration**: Automatic settings upgrade and validation -- **Fallback Mechanism**: Automatic provider switching on failure + +- **Nova-3 Model**: 98% accuracy with $0.0043/min (70% cost reduction) +- **Clean Architecture**: Domain-driven design with clear separation of concerns +- **Intelligent Provider Selection**: Best provider based on file size and format +- **Real-time Progress**: Status bar progress indicator with cancellation support +- **Async Processing**: Non-blocking background processing +- **Memory Management**: Built-in memory monitoring and optimization +- **Performance Benchmarking**: Integrated performance monitoring tools +- **Error Boundaries**: Comprehensive error handling and recovery +- **Dependency Injection**: IoC container for better testability +- **Event-Driven Architecture**: Decoupled components with EventManager +- **Batch Processing**: Efficient batch request handling +- **Caching Layer**: Smart caching for improved performance +- **Settings Migration**: Automatic settings upgrade and validation +- **Fallback Mechanism**: Automatic provider switching on failure ## Installation ### Manual Installation #### From Releases + 1. Download the latest release from [Releases](https://github.com/asyouplz/SpeechNote/releases) 2. Extract files to your vault's `.obsidian/plugins/obsidian-speech-to-text/` folder 3. Restart Obsidian 4. Enable "Speech to Text" in Community Plugins settings #### Development Build + ```bash # Clone repository git clone https://github.com/asyouplz/SpeechNote.git @@ -86,19 +92,22 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi ### API Key Configuration #### 1. Choose Provider + 1. Open Obsidian Settings → "Speech to Text" 2. Select "Transcription Provider": - - **OpenAI Whisper**: High quality, stable - - **Deepgram**: Fast speed, large file support - - **Auto**: Automatic selection (recommended) + - **OpenAI Whisper**: High quality, stable + - **Deepgram**: Fast speed, large file support + - **Auto**: Automatic selection (recommended) #### 2. Get OpenAI API Key (for Whisper) + 1. Visit [OpenAI Platform](https://platform.openai.com/api-keys) 2. Sign in or create account 3. Click "Create new secret key" 4. Copy the key (⚠️ shown only once) #### 3. Get Deepgram API Key (for Deepgram) + 1. Visit [Deepgram Console](https://console.deepgram.com/) 2. Sign up or sign in 3. Go to "API Keys" menu @@ -106,11 +115,12 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi 5. Copy the API key #### 4. Configure Plugin + 1. Open Obsidian Settings (Cmd/Ctrl + ,) 2. Select "Speech to Text" from left menu 3. Enter your API key(s): - - "OpenAI API Key" (for Whisper) - - "Deepgram API Key" (for Deepgram) + - "OpenAI API Key" (for Whisper) + - "Deepgram API Key" (for Deepgram) 4. Save settings ## Usage @@ -118,6 +128,7 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi ### Basic Usage #### Method 1: Command Palette + 1. **Open Command Palette**: `Cmd/Ctrl + P` 2. **Search**: "Transcribe audio file" 3. **Select File**: Choose audio file from list @@ -125,12 +136,14 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi 5. **Complete**: Text automatically inserted into note #### Method 2: Context Menu ✨ + 1. **File Explorer**: Find audio file 2. **Right Click**: Right-click on audio file 3. **Select**: "Transcribe audio file" 4. **Auto Process**: Transcription starts and inserts result #### Method 3: Hotkeys + 1. **Settings**: Settings → Hotkeys → search "Transcribe audio file" 2. **Set Hotkey**: Assign preferred key combination 3. **Execute**: Use hotkey for quick access @@ -138,12 +151,14 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi ### 🎭 Using Speaker Diarization #### Enable Speaker Diarization + 1. **Open Settings**: Settings → Speech to Text → Deepgram Settings 2. **Enable Diarization**: Toggle "Speaker Diarization" to ON 3. **Select Nova-3**: Choose "Nova-3" model (default for new installations) 4. **Save Settings**: Apply configuration #### Example Results + ``` 🎙️ Multi-speaker meeting audio: @@ -158,113 +173,123 @@ Speaker 3: I think we should extend the deadline by one week. ``` #### Best Practices for Speaker Diarization -- **Clear Audio**: Use high-quality recordings for better accuracy -- **Speaker Separation**: Ensure speakers don't talk simultaneously -- **Minimum Duration**: Each speaker segment should be at least 2-3 seconds -- **Audio Format**: Use M4A, MP3, or WAV for optimal results + +- **Clear Audio**: Use high-quality recordings for better accuracy +- **Speaker Separation**: Ensure speakers don't talk simultaneously +- **Minimum Duration**: Each speaker segment should be at least 2-3 seconds +- **Audio Format**: Use M4A, MP3, or WAV for optimal results ### Supported Audio Formats -| Format | Extension | Whisper | Deepgram | Max Size | Diarization | Description | -|--------|-----------|---------|----------|----------|-------------|--------------| -| M4A | .m4a | ✅ | ✅ | 25MB/2GB | ✅ | Apple default recording format | -| MP3 | .mp3 | ✅ | ✅ | 25MB/2GB | ✅ | Universal audio format | -| WAV | .wav | ✅ | ✅ | 25MB/2GB | ✅ | Lossless, large file size | -| MP4 | .mp4 | ✅ | ✅ | 25MB/2GB | ✅ | Audio from video files | -| WebM | .webm | ❌ | ✅ | -/2GB | ✅ | Web streaming format | -| OGG | .ogg | ❌ | ✅ | -/2GB | ✅ | Open source audio format | -| FLAC | .flac | ❌ | ✅ | -/2GB | ✅ | Lossless compression | +| Format | Extension | Whisper | Deepgram | Max Size | Diarization | Description | +| ------ | --------- | ------- | -------- | -------- | ----------- | ------------------------------ | +| M4A | .m4a | ✅ | ✅ | 25MB/2GB | ✅ | Apple default recording format | +| MP3 | .mp3 | ✅ | ✅ | 25MB/2GB | ✅ | Universal audio format | +| WAV | .wav | ✅ | ✅ | 25MB/2GB | ✅ | Lossless, large file size | +| MP4 | .mp4 | ✅ | ✅ | 25MB/2GB | ✅ | Audio from video files | +| WebM | .webm | ❌ | ✅ | -/2GB | ✅ | Web streaming format | +| OGG | .ogg | ❌ | ✅ | -/2GB | ✅ | Open source audio format | +| FLAC | .flac | ❌ | ✅ | -/2GB | ✅ | Lossless compression | ## Settings ### Main Settings -- **Provider Selection**: - - Auto: Intelligent selection based on file - - OpenAI Whisper: High quality, stable performance - - Deepgram: Fast speed, speaker diarization support -- **Language**: Auto-detect or specific language selection -- **Insert Position**: - - Cursor position - - Beginning of note - - End of note -- **Auto-insert**: Automatic text insertion after transcription + +- **Provider Selection**: + - Auto: Intelligent selection based on file + - OpenAI Whisper: High quality, stable performance + - Deepgram: Fast speed, speaker diarization support +- **Language**: Auto-detect or specific language selection +- **Insert Position**: + - Cursor position + - Beginning of note + - End of note +- **Auto-insert**: Automatic text insertion after transcription ### Deepgram Settings -- **Model Selection**: - - Nova-3 (recommended): 98% accuracy, speaker diarization - - Nova-2: Previous generation, high accuracy - - Nova/Enhanced/Base: Legacy models -- **Features**: - - Speaker Diarization: Automatic speaker separation - - Smart Format: Intelligent text formatting - - Punctuation: Automatic punctuation - - Utterances: Segment by natural speech patterns - - Paragraphs: Automatic paragraph detection + +- **Model Selection**: + - Nova-3 (recommended): 98% accuracy, speaker diarization + - Nova-2: Previous generation, high accuracy + - Nova/Enhanced/Base: Legacy models +- **Features**: + - Speaker Diarization: Automatic speaker separation + - Smart Format: Intelligent text formatting + - Punctuation: Automatic punctuation + - Utterances: Segment by natural speech patterns + - Paragraphs: Automatic paragraph detection ### Advanced Settings -- **Performance**: - - Batch Processing: Process multiple files efficiently - - Memory Limits: Configure memory usage thresholds - - Cache Duration: Result caching timeouts - - Max Parallel Requests: Control concurrent API calls - - Circuit Breaker: Automatic failure protection -- **Network**: - - Request Timeout: API request timeout settings - - Retry Policy: Automatic retry configuration - - Fallback Provider: Backup provider on failure - - Health Checks: Monitor provider availability -- **Cost Management**: - - Monthly Budget: Set spending limits - - Cost Limits: Per-request cost controls - - Budget Alerts: Get notified at threshold - - Auto Cost Optimization: Intelligent provider selection based on cost -- **Quality Control**: - - Quality Threshold: Minimum acceptable accuracy - - Confidence Level: Minimum transcription confidence - - Strict Language Mode: Enforce language consistency - - Post-Processing: Additional text refinement -- **Text Formatting**: - - Plain Text: Standard text output - - Markdown: Markdown-formatted output - - Quote Block: Insert as blockquote - - Bullet List: Format as list items - - Heading: Insert as headings - - Code Block: Format as code - - Callout: Use Obsidian callouts -- **A/B Testing**: - - Provider Comparison: Test multiple providers - - Traffic Split: Percentage-based routing - - Metric Tracking: Compare accuracy, speed, cost - - Duration Control: Set test duration -- **Large File Handling**: - - Auto Chunking: Split files automatically - - Chunk Size: Configure chunk size (MB) - - Overlap: Set chunk overlap for continuity -- **Development**: - - Debug Mode: Detailed logging - - Performance Monitoring: Track performance metrics - - Error Reporting: Enhanced error details - - Metrics Retention: Configure data retention period + +- **Performance**: + - Batch Processing: Process multiple files efficiently + - Memory Limits: Configure memory usage thresholds + - Cache Duration: Result caching timeouts + - Max Parallel Requests: Control concurrent API calls + - Circuit Breaker: Automatic failure protection +- **Network**: + - Request Timeout: API request timeout settings + - Retry Policy: Automatic retry configuration + - Fallback Provider: Backup provider on failure + - Health Checks: Monitor provider availability +- **Cost Management**: + - Monthly Budget: Set spending limits + - Cost Limits: Per-request cost controls + - Budget Alerts: Get notified at threshold + - Auto Cost Optimization: Intelligent provider selection based on cost +- **Quality Control**: + - Quality Threshold: Minimum acceptable accuracy + - Confidence Level: Minimum transcription confidence + - Strict Language Mode: Enforce language consistency + - Post-Processing: Additional text refinement +- **Text Formatting**: + - Plain Text: Standard text output + - Markdown: Markdown-formatted output + - Quote Block: Insert as blockquote + - Bullet List: Format as list items + - Heading: Insert as headings + - Code Block: Format as code + - Callout: Use Obsidian callouts +- **A/B Testing**: + - Provider Comparison: Test multiple providers + - Traffic Split: Percentage-based routing + - Metric Tracking: Compare accuracy, speed, cost + - Duration Control: Set test duration +- **Large File Handling**: + - Auto Chunking: Split files automatically + - Chunk Size: Configure chunk size (MB) + - Overlap: Set chunk overlap for continuity +- **Development**: + - Debug Mode: Detailed logging + - Performance Monitoring: Track performance metrics + - Error Reporting: Enhanced error details + - Metrics Retention: Configure data retention period ## Troubleshooting ### Common Issues #### "Invalid API Key" Error + **Solutions:** + 1. Verify API key format (OpenAI: starts with `sk-`) 2. Check API key status on provider dashboard 3. Ensure sufficient credits/active subscription 4. Remove any extra spaces from key #### "File too large" Error + **Solutions:** + 1. Check file size limits (Whisper: 25MB, Deepgram: 2GB) 2. Use Deepgram for larger files 3. Compress audio files if needed #### Speaker Diarization Not Working + **Solutions:** + 1. Ensure Nova-3 model is selected (required for diarization) 2. Check "Speaker Diarization" is enabled in Deepgram settings 3. Verify audio quality (clear speakers, minimal overlap) @@ -272,34 +297,40 @@ Speaker 3: I think we should extend the deadline by one week. 5. Check minimum speaker duration (2-3 seconds per segment) #### No Audio Files Found + **Solutions:** + 1. Verify supported formats: .m4a, .mp3, .wav, .mp4, etc. 2. Ensure files are in vault folder 3. Restart Obsidian 4. Wait for file indexing (large vaults) #### Network Errors + **Solutions:** + 1. Check internet connection 2. Verify VPN/proxy settings 3. Check provider API status ## Commands -| Command | Description | Status | -|---------|-------------|---------| +| Command | Description | Status | +| ------------------------- | -------------------------------- | ------------ | | **Transcribe audio file** | Select and transcribe audio file | ✅ Available | -| **Cancel transcription** | Cancel ongoing transcription | ✅ Available | +| **Cancel transcription** | Cancel ongoing transcription | ✅ Available | ## Development ### Prerequisites -- Node.js 16.0.0+ -- npm 7.0.0+ -- Obsidian 0.15.0+ -- TypeScript 4.7.4+ + +- Node.js 16.0.0+ +- npm 7.0.0+ +- Obsidian 0.15.0+ +- TypeScript 4.7.4+ ### Development Setup + ```bash # Clone repository git clone https://github.com/asyouplz/SpeechNote.git @@ -339,6 +370,7 @@ npm run ci # Full CI pipeline ``` ### Project Structure + ``` SpeechNote/ ├── src/ @@ -408,26 +440,29 @@ SpeechNote/ ## Architecture Highlights ### Clean Architecture Layers -- **Application Layer**: Orchestrates use cases and coordinates domain logic -- **Core Layer**: Business logic and transcription services -- **Domain Layer**: Business entities and domain events -- **Infrastructure Layer**: External services and API integrations -- **UI Layer**: User interface components and settings management + +- **Application Layer**: Orchestrates use cases and coordinates domain logic +- **Core Layer**: Business logic and transcription services +- **Domain Layer**: Business entities and domain events +- **Infrastructure Layer**: External services and API integrations +- **UI Layer**: User interface components and settings management ### Design Patterns Used -- **Factory Pattern**: TranscriberFactory for provider instantiation -- **Adapter Pattern**: API adapters for provider integration -- **Observer Pattern**: Event-driven architecture with EventManager -- **Strategy Pattern**: Multiple transcription providers -- **Repository Pattern**: Storage management abstraction -- **Dependency Injection**: IoC container for loose coupling -- **Error Boundary Pattern**: Comprehensive error handling + +- **Factory Pattern**: TranscriberFactory for provider instantiation +- **Adapter Pattern**: API adapters for provider integration +- **Observer Pattern**: Event-driven architecture with EventManager +- **Strategy Pattern**: Multiple transcription providers +- **Repository Pattern**: Storage management abstraction +- **Dependency Injection**: IoC container for loose coupling +- **Error Boundary Pattern**: Comprehensive error handling ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ### Contribution Process + 1. **Fork** the repository 2. **Create** feature branch: `git checkout -b feature/amazing-feature` 3. **Commit** changes: `git commit -m 'feat: add amazing feature'` @@ -435,10 +470,11 @@ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guid 5. **Create** Pull Request ### Code Style Guidelines -- Follow TypeScript best practices -- Use ESLint and Prettier configurations -- Write unit tests for new features -- Update documentation for API changes + +- Follow TypeScript best practices +- Use ESLint and Prettier configurations +- Write unit tests for new features +- Update documentation for API changes ## License @@ -447,58 +483,67 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Credits ### Acknowledgments -- **Obsidian Team**: Obsidian Plugin API -- **OpenAI**: Whisper API -- **Deepgram**: Speech-to-Text API -- **Community**: Obsidian community feedback and contributions + +- **Obsidian Team**: Obsidian Plugin API +- **OpenAI**: Whisper API +- **Deepgram**: Speech-to-Text API +- **Community**: Obsidian community feedback and contributions ### Built With -- TypeScript -- ESBuild -- Jest -- ESLint & Prettier + +- TypeScript +- ESBuild +- Jest +- ESLint & Prettier ## Support ### Need Help? -- 🐛 **Bug Reports**: [GitHub Issues](https://github.com/asyouplz/SpeechNote/issues) -- 💡 **Feature Requests**: [GitHub Issues](https://github.com/asyouplz/SpeechNote/issues) -- 💬 **Discussions**: [GitHub Discussions](https://github.com/asyouplz/SpeechNote/discussions) + +- 🐛 **Bug Reports**: [GitHub Issues](https://github.com/asyouplz/SpeechNote/issues) +- 💡 **Feature Requests**: [GitHub Issues](https://github.com/asyouplz/SpeechNote/issues) +- 💬 **Discussions**: [GitHub Discussions](https://github.com/asyouplz/SpeechNote/discussions) ### Show Your Support + If this project helped you: -- ⭐ Star on GitHub -- 🐦 Share on social media -- ☕ [Buy me a coffee](https://buymeacoffee.com/asyouplz) + +- ⭐ Star on GitHub +- 🐦 Share on social media +- ☕ [Buy me a coffee](https://buymeacoffee.com/asyouplz) ## Recent Updates ### 🚀 v3.0.4 (Latest) - Settings Stability & Deepgram Cleanup -- 🛠️ **Settings Stability**: Improves plugin settings reliability -- 🧹 **Deepgram Refactor**: Cleanup and refactor for provider integration -- 🧩 **General Fixes**: Minor bug fixes and improvements + +- 🛠️ **Settings Stability**: Improves plugin settings reliability +- 🧹 **Deepgram Refactor**: Cleanup and refactor for provider integration +- 🧩 **General Fixes**: Minor bug fixes and improvements ### v3.0.1 - Enterprise Architecture & Performance -- 🏗️ **Clean Architecture**: Domain-driven design with clear separation of concerns -- 🎯 **Modular Structure**: Organized into application, core, domain, infrastructure layers -- ⚡ **Performance Monitoring**: Built-in performance benchmarking and memory management -- 🛡️ **Error Boundaries**: Comprehensive error handling with ErrorBoundary pattern -- 📦 **Dependency Injection**: DependencyContainer for better testability -- 🧪 **Test Coverage**: Unit, integration, and E2E test suites -- 🔄 **Settings Migration**: Automatic settings migration and validation -- 📊 **Batch Processing**: Efficient batch request management + +- 🏗️ **Clean Architecture**: Domain-driven design with clear separation of concerns +- 🎯 **Modular Structure**: Organized into application, core, domain, infrastructure layers +- ⚡ **Performance Monitoring**: Built-in performance benchmarking and memory management +- 🛡️ **Error Boundaries**: Comprehensive error handling with ErrorBoundary pattern +- 📦 **Dependency Injection**: DependencyContainer for better testability +- 🧪 **Test Coverage**: Unit, integration, and E2E test suites +- 🔄 **Settings Migration**: Automatic settings migration and validation +- 📊 **Batch Processing**: Efficient batch request management ### v3.0.0 - Nova-3 & Speaker Diarization -- ✨ **Nova-3 Model**: Default model upgrade with 98% accuracy -- 🎭 **Speaker Diarization**: Complete implementation with "Speaker 1:", "Speaker 2:" format -- 💰 **Cost Optimization**: 70% cost reduction with Deepgram Nova-3 -- 🔧 **Code Quality**: Improved type safety and code organization + +- ✨ **Nova-3 Model**: Default model upgrade with 98% accuracy +- 🎭 **Speaker Diarization**: Complete implementation with "Speaker 1:", "Speaker 2:" format +- 💰 **Cost Optimization**: 70% cost reduction with Deepgram Nova-3 +- 🔧 **Code Quality**: Improved type safety and code organization ### v1.0.0 - Initial Release -- 🎉 **Multi-Provider Support**: OpenAI Whisper & Deepgram integration -- 🌐 **Multi-language Support**: 40+ languages with auto-detection -- 📝 **Smart Text Insertion**: Flexible insertion options -- 🎯 **Context Menu Integration**: Right-click transcription + +- 🎉 **Multi-Provider Support**: OpenAI Whisper & Deepgram integration +- 🌐 **Multi-language Support**: 40+ languages with auto-detection +- 📝 **Smart Text Insertion**: Flexible insertion options +- 🎯 **Context Menu Integration**: Right-click transcription --- @@ -507,51 +552,57 @@ If this project helped you: ## 주요 기능 ### 🎙️ 다중 공급자 오디오 변환 -- **OpenAI Whisper**: 높은 정확도, 안정적인 성능 -- **Deepgram Nova 3**: 최신 모델, 98% 정확도, 70% 비용 절감 -- **화자 분리**: "Speaker 1:", "Speaker 2:" 형식으로 자동 화자 구분 -- **자동 선택**: 각 파일에 가장 적합한 공급자 자동 선택 -- **지원 형식**: M4A, MP3, WAV, MP4, WebM, OGG, FLAC + +- **OpenAI Whisper**: 높은 정확도, 안정적인 성능 +- **Deepgram Nova 3**: 최신 모델, 98% 정확도, 70% 비용 절감 +- **화자 분리**: "Speaker 1:", "Speaker 2:" 형식으로 자동 화자 구분 +- **자동 선택**: 각 파일에 가장 적합한 공급자 자동 선택 +- **지원 형식**: M4A, MP3, WAV, MP4, WebM, OGG, FLAC ### 🌐 다국어 지원 -- **자동 감지**: 자동 언어 인식 -- **40개 이상 언어**: 한국어, 영어, 일본어, 중국어, 스페인어, 프랑스어, 독일어 등 -- **공급자 최적화**: 각 공급자별 언어 최적화 + +- **자동 감지**: 자동 언어 인식 +- **40개 이상 언어**: 한국어, 영어, 일본어, 중국어, 스페인어, 프랑스어, 독일어 등 +- **공급자 최적화**: 각 공급자별 언어 최적화 ### 📝 스마트 텍스트 삽입 및 화자 인식 -- **커서 위치**: 현재 커서 위치에 삽입 -- **노트 위치**: 노트의 시작 또는 끝에 삽입 -- **자동 노트 생성**: 활성 편집기가 없으면 새 노트 생성 -- **화자 분리**: 자동 화자 식별 및 레이블링 -- **다중 화자 지원**: 회의, 인터뷰, 대화를 명확하게 구분 + +- **커서 위치**: 현재 커서 위치에 삽입 +- **노트 위치**: 노트의 시작 또는 끝에 삽입 +- **자동 노트 생성**: 활성 편집기가 없으면 새 노트 생성 +- **화자 분리**: 자동 화자 식별 및 레이블링 +- **다중 화자 지원**: 회의, 인터뷰, 대화를 명확하게 구분 ### ⚡ 성능 및 아키텍처 -- **Nova-3 모델**: 98% 정확도, 분당 $0.0043 (70% 비용 절감) -- **클린 아키텍처**: 도메인 주도 설계 및 관심사 분리 -- **지능형 공급자 선택**: 파일 크기와 형식에 따른 최적 공급자 선택 -- **실시간 진행 상황**: 취소 지원이 포함된 상태 표시줄 진행 표시기 -- **비동기 처리**: 논블로킹 백그라운드 처리 -- **메모리 관리**: 내장 메모리 모니터링 및 최적화 -- **성능 벤치마킹**: 통합 성능 모니터링 도구 -- **에러 바운더리**: 포괄적인 오류 처리 및 복구 -- **의존성 주입**: 더 나은 테스트 가능성을 위한 IoC 컨테이너 -- **이벤트 기반 아키텍처**: EventManager를 통한 분리된 컴포넌트 -- **배치 처리**: 효율적인 배치 요청 처리 -- **캐싱 레이어**: 성능 향상을 위한 스마트 캐싱 -- **설정 마이그레이션**: 자동 설정 업그레이드 및 검증 -- **폴백 메커니즘**: 실패 시 자동 공급자 전환 + +- **Nova-3 모델**: 98% 정확도, 분당 $0.0043 (70% 비용 절감) +- **클린 아키텍처**: 도메인 주도 설계 및 관심사 분리 +- **지능형 공급자 선택**: 파일 크기와 형식에 따른 최적 공급자 선택 +- **실시간 진행 상황**: 취소 지원이 포함된 상태 표시줄 진행 표시기 +- **비동기 처리**: 논블로킹 백그라운드 처리 +- **메모리 관리**: 내장 메모리 모니터링 및 최적화 +- **성능 벤치마킹**: 통합 성능 모니터링 도구 +- **에러 바운더리**: 포괄적인 오류 처리 및 복구 +- **의존성 주입**: 더 나은 테스트 가능성을 위한 IoC 컨테이너 +- **이벤트 기반 아키텍처**: EventManager를 통한 분리된 컴포넌트 +- **배치 처리**: 효율적인 배치 요청 처리 +- **캐싱 레이어**: 성능 향상을 위한 스마트 캐싱 +- **설정 마이그레이션**: 자동 설정 업그레이드 및 검증 +- **폴백 메커니즘**: 실패 시 자동 공급자 전환 ## 설치 ### 수동 설치 #### 릴리즈에서 설치 + 1. [Releases](https://github.com/asyouplz/SpeechNote/releases)에서 최신 릴리즈 다운로드 2. vault의 `.obsidian/plugins/obsidian-speech-to-text/` 폴더에 파일 압축 해제 3. Obsidian 재시작 4. 커뮤니티 플러그인 설정에서 "Speech to Text" 활성화 #### 개발 빌드 + ```bash # 저장소 복제 git clone https://github.com/asyouplz/SpeechNote.git @@ -572,19 +623,22 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi ### API 키 설정 #### 1. 공급자 선택 + 1. Obsidian 설정 → "Speech to Text" 열기 2. "Transcription Provider" 선택: - - **OpenAI Whisper**: 고품질, 안정적 - - **Deepgram**: 빠른 속도, 대용량 파일 지원 - - **Auto**: 자동 선택 (권장) + - **OpenAI Whisper**: 고품질, 안정적 + - **Deepgram**: 빠른 속도, 대용량 파일 지원 + - **Auto**: 자동 선택 (권장) #### 2. OpenAI API 키 받기 (Whisper용) + 1. [OpenAI Platform](https://platform.openai.com/api-keys) 방문 2. 로그인 또는 계정 생성 3. "Create new secret key" 클릭 4. 키 복사 (⚠️ 한 번만 표시됨) #### 3. Deepgram API 키 받기 (Deepgram용) + 1. [Deepgram Console](https://console.deepgram.com/) 방문 2. 회원가입 또는 로그인 3. "API Keys" 메뉴로 이동 @@ -592,11 +646,12 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi 5. API 키 복사 #### 4. 플러그인 설정 + 1. Obsidian 설정 열기 (Cmd/Ctrl + ,) 2. 왼쪽 메뉴에서 "Speech to Text" 선택 3. API 키 입력: - - "OpenAI API Key" (Whisper용) - - "Deepgram API Key" (Deepgram용) + - "OpenAI API Key" (Whisper용) + - "Deepgram API Key" (Deepgram용) 4. 설정 저장 ## 사용법 @@ -604,6 +659,7 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi ### 기본 사용법 #### 방법 1: 명령 팔레트 + 1. **명령 팔레트 열기**: `Cmd/Ctrl + P` 2. **검색**: "Transcribe audio file" 3. **파일 선택**: 목록에서 오디오 파일 선택 @@ -611,12 +667,14 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi 5. **완료**: 텍스트가 자동으로 노트에 삽입됨 #### 방법 2: 컨텍스트 메뉴 ✨ + 1. **파일 탐색기**: 오디오 파일 찾기 2. **오른쪽 클릭**: 오디오 파일에서 오른쪽 클릭 3. **선택**: "Transcribe audio file" 4. **자동 처리**: 변환 시작 및 결과 삽입 #### 방법 3: 단축키 + 1. **설정**: 설정 → 단축키 → "Transcribe audio file" 검색 2. **단축키 설정**: 선호하는 키 조합 할당 3. **실행**: 단축키를 사용하여 빠른 액세스 @@ -624,12 +682,14 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/obsidi ### 🎭 화자 분리 사용하기 #### 화자 분리 활성화 + 1. **설정 열기**: 설정 → Speech to Text → Deepgram 설정 2. **분리 활성화**: "Speaker Diarization"을 ON으로 전환 3. **Nova-3 선택**: "Nova-3" 모델 선택 (새 설치 시 기본값) 4. **설정 저장**: 구성 적용 #### 결과 예시 + ``` 🎙️ 다중 화자 회의 오디오: @@ -644,113 +704,123 @@ Speaker 3: 마감일을 일주일 연장해야 할 것 같습니다. ``` #### 화자 분리 모범 사례 -- **깨끗한 오디오**: 더 나은 정확도를 위해 고품질 녹음 사용 -- **화자 분리**: 화자들이 동시에 말하지 않도록 보장 -- **최소 길이**: 각 화자 세그먼트는 최소 2-3초여야 함 -- **오디오 형식**: 최적의 결과를 위해 M4A, MP3 또는 WAV 사용 + +- **깨끗한 오디오**: 더 나은 정확도를 위해 고품질 녹음 사용 +- **화자 분리**: 화자들이 동시에 말하지 않도록 보장 +- **최소 길이**: 각 화자 세그먼트는 최소 2-3초여야 함 +- **오디오 형식**: 최적의 결과를 위해 M4A, MP3 또는 WAV 사용 ### 지원 오디오 형식 -| 형식 | 확장자 | Whisper | Deepgram | 최대 크기 | 화자분리 | 설명 | -|--------|-----------|---------|----------|----------|-------------|--------------| -| M4A | .m4a | ✅ | ✅ | 25MB/2GB | ✅ | Apple 기본 녹음 형식 | -| MP3 | .mp3 | ✅ | ✅ | 25MB/2GB | ✅ | 범용 오디오 형식 | -| WAV | .wav | ✅ | ✅ | 25MB/2GB | ✅ | 무손실, 큰 파일 크기 | -| MP4 | .mp4 | ✅ | ✅ | 25MB/2GB | ✅ | 비디오 파일의 오디오 | -| WebM | .webm | ❌ | ✅ | -/2GB | ✅ | 웹 스트리밍 형식 | -| OGG | .ogg | ❌ | ✅ | -/2GB | ✅ | 오픈 소스 오디오 형식 | -| FLAC | .flac | ❌ | ✅ | -/2GB | ✅ | 무손실 압축 | +| 형식 | 확장자 | Whisper | Deepgram | 최대 크기 | 화자분리 | 설명 | +| ---- | ------ | ------- | -------- | --------- | -------- | --------------------- | +| M4A | .m4a | ✅ | ✅ | 25MB/2GB | ✅ | Apple 기본 녹음 형식 | +| MP3 | .mp3 | ✅ | ✅ | 25MB/2GB | ✅ | 범용 오디오 형식 | +| WAV | .wav | ✅ | ✅ | 25MB/2GB | ✅ | 무손실, 큰 파일 크기 | +| MP4 | .mp4 | ✅ | ✅ | 25MB/2GB | ✅ | 비디오 파일의 오디오 | +| WebM | .webm | ❌ | ✅ | -/2GB | ✅ | 웹 스트리밍 형식 | +| OGG | .ogg | ❌ | ✅ | -/2GB | ✅ | 오픈 소스 오디오 형식 | +| FLAC | .flac | ❌ | ✅ | -/2GB | ✅ | 무손실 압축 | ## 설정 ### 기본 설정 -- **공급자 선택**: - - Auto (자동): 파일에 따라 지능적 선택 - - OpenAI Whisper: 고품질, 안정적인 성능 - - Deepgram: 빠른 속도, 화자 분리 지원 -- **언어**: 자동 감지 또는 특정 언어 선택 -- **삽입 위치**: - - 커서 위치 - - 노트 시작 - - 노트 끝 -- **자동 삽입**: 변환 후 자동 텍스트 삽입 + +- **공급자 선택**: + - Auto (자동): 파일에 따라 지능적 선택 + - OpenAI Whisper: 고품질, 안정적인 성능 + - Deepgram: 빠른 속도, 화자 분리 지원 +- **언어**: 자동 감지 또는 특정 언어 선택 +- **삽입 위치**: + - 커서 위치 + - 노트 시작 + - 노트 끝 +- **자동 삽입**: 변환 후 자동 텍스트 삽입 ### Deepgram 설정 -- **모델 선택**: - - Nova-3 (권장): 98% 정확도, 화자 분리 - - Nova-2: 이전 세대, 높은 정확도 - - Nova/Enhanced/Base: 레거시 모델 -- **기능**: - - 화자 분리: 자동 화자 구분 - - 스마트 포맷: 지능형 텍스트 포맷팅 - - 문장 부호: 자동 구두점 - - 발화: 자연스러운 말 패턴으로 분할 - - 문단: 자동 문단 감지 + +- **모델 선택**: + - Nova-3 (권장): 98% 정확도, 화자 분리 + - Nova-2: 이전 세대, 높은 정확도 + - Nova/Enhanced/Base: 레거시 모델 +- **기능**: + - 화자 분리: 자동 화자 구분 + - 스마트 포맷: 지능형 텍스트 포맷팅 + - 문장 부호: 자동 구두점 + - 발화: 자연스러운 말 패턴으로 분할 + - 문단: 자동 문단 감지 ### 고급 설정 -- **성능**: - - 배치 처리: 여러 파일을 효율적으로 처리 - - 메모리 제한: 메모리 사용 임계값 구성 - - 캐시 지속 시간: 결과 캐싱 타임아웃 - - 최대 병렬 요청: 동시 API 호출 제어 - - 서킷 브레이커: 자동 장애 보호 -- **네트워크**: - - 요청 타임아웃: API 요청 타임아웃 설정 - - 재시도 정책: 자동 재시도 구성 - - 폴백 공급자: 실패 시 백업 공급자 - - 상태 체크: 공급자 가용성 모니터링 -- **비용 관리**: - - 월간 예산: 지출 한도 설정 - - 비용 제한: 요청당 비용 제어 - - 예산 알림: 임계값 도달 시 알림 - - 자동 비용 최적화: 비용 기반 지능형 공급자 선택 -- **품질 제어**: - - 품질 임계값: 최소 허용 정확도 - - 신뢰도 수준: 최소 변환 신뢰도 - - 엄격한 언어 모드: 언어 일관성 강제 - - 후처리: 추가 텍스트 정제 -- **텍스트 포맷팅**: - - 일반 텍스트: 표준 텍스트 출력 - - 마크다운: 마크다운 형식 출력 - - 인용구 블록: 인용구로 삽입 - - 글머리 기호: 목록 항목으로 포맷 - - 제목: 제목으로 삽입 - - 코드 블록: 코드로 포맷 - - 콜아웃: Obsidian 콜아웃 사용 -- **A/B 테스팅**: - - 공급자 비교: 여러 공급자 테스트 - - 트래픽 분할: 백분율 기반 라우팅 - - 메트릭 추적: 정확도, 속도, 비용 비교 - - 기간 제어: 테스트 기간 설정 -- **대용량 파일 처리**: - - 자동 청킹: 파일 자동 분할 - - 청크 크기: 청크 크기 구성 (MB) - - 오버랩: 연속성을 위한 청크 오버랩 설정 -- **개발**: - - 디버그 모드: 상세 로깅 - - 성능 모니터링: 성능 메트릭 추적 - - 오류 보고: 향상된 오류 세부 정보 - - 메트릭 보존: 데이터 보존 기간 구성 + +- **성능**: + - 배치 처리: 여러 파일을 효율적으로 처리 + - 메모리 제한: 메모리 사용 임계값 구성 + - 캐시 지속 시간: 결과 캐싱 타임아웃 + - 최대 병렬 요청: 동시 API 호출 제어 + - 서킷 브레이커: 자동 장애 보호 +- **네트워크**: + - 요청 타임아웃: API 요청 타임아웃 설정 + - 재시도 정책: 자동 재시도 구성 + - 폴백 공급자: 실패 시 백업 공급자 + - 상태 체크: 공급자 가용성 모니터링 +- **비용 관리**: + - 월간 예산: 지출 한도 설정 + - 비용 제한: 요청당 비용 제어 + - 예산 알림: 임계값 도달 시 알림 + - 자동 비용 최적화: 비용 기반 지능형 공급자 선택 +- **품질 제어**: + - 품질 임계값: 최소 허용 정확도 + - 신뢰도 수준: 최소 변환 신뢰도 + - 엄격한 언어 모드: 언어 일관성 강제 + - 후처리: 추가 텍스트 정제 +- **텍스트 포맷팅**: + - 일반 텍스트: 표준 텍스트 출력 + - 마크다운: 마크다운 형식 출력 + - 인용구 블록: 인용구로 삽입 + - 글머리 기호: 목록 항목으로 포맷 + - 제목: 제목으로 삽입 + - 코드 블록: 코드로 포맷 + - 콜아웃: Obsidian 콜아웃 사용 +- **A/B 테스팅**: + - 공급자 비교: 여러 공급자 테스트 + - 트래픽 분할: 백분율 기반 라우팅 + - 메트릭 추적: 정확도, 속도, 비용 비교 + - 기간 제어: 테스트 기간 설정 +- **대용량 파일 처리**: + - 자동 청킹: 파일 자동 분할 + - 청크 크기: 청크 크기 구성 (MB) + - 오버랩: 연속성을 위한 청크 오버랩 설정 +- **개발**: + - 디버그 모드: 상세 로깅 + - 성능 모니터링: 성능 메트릭 추적 + - 오류 보고: 향상된 오류 세부 정보 + - 메트릭 보존: 데이터 보존 기간 구성 ## 문제 해결 ### 일반적인 문제 #### "Invalid API Key" 오류 + **해결 방법:** + 1. API 키 형식 확인 (OpenAI: `sk-`로 시작) 2. 공급자 대시보드에서 API 키 상태 확인 3. 충분한 크레딧/활성 구독 확인 4. 키에서 공백 제거 #### "File too large" 오류 + **해결 방법:** + 1. 파일 크기 제한 확인 (Whisper: 25MB, Deepgram: 2GB) 2. 큰 파일은 Deepgram 사용 3. 필요시 오디오 파일 압축 #### 화자 분리가 작동하지 않음 + **해결 방법:** + 1. Nova-3 모델이 선택되었는지 확인 (화자 분리에 필요) 2. Deepgram 설정에서 "Speaker Diarization"이 활성화되었는지 확인 3. 오디오 품질 확인 (명확한 화자, 최소 중첩) @@ -758,34 +828,40 @@ Speaker 3: 마감일을 일주일 연장해야 할 것 같습니다. 5. 최소 화자 길이 확인 (세그먼트당 2-3초) #### 오디오 파일을 찾을 수 없음 + **해결 방법:** + 1. 지원 형식 확인: .m4a, .mp3, .wav, .mp4 등 2. 파일이 vault 폴더에 있는지 확인 3. Obsidian 재시작 4. 파일 인덱싱 대기 (큰 vault의 경우) #### 네트워크 오류 + **해결 방법:** + 1. 인터넷 연결 확인 2. VPN/프록시 설정 확인 3. 공급자 API 상태 확인 ## 명령어 -| 명령어 | 설명 | 상태 | -|---------|-------------|---------| +| 명령어 | 설명 | 상태 | +| ------------------------- | ------------------------ | ------------ | | **Transcribe audio file** | 오디오 파일 선택 및 변환 | ✅ 사용 가능 | -| **Cancel transcription** | 진행 중인 변환 취소 | ✅ 사용 가능 | +| **Cancel transcription** | 진행 중인 변환 취소 | ✅ 사용 가능 | ## 개발 ### 필수 요구 사항 -- Node.js 16.0.0+ -- npm 7.0.0+ -- Obsidian 0.15.0+ -- TypeScript 4.7.4+ + +- Node.js 16.0.0+ +- npm 7.0.0+ +- Obsidian 0.15.0+ +- TypeScript 4.7.4+ ### 개발 환경 설정 + ```bash # 저장소 복제 git clone https://github.com/asyouplz/SpeechNote.git @@ -825,6 +901,7 @@ npm run ci # 전체 CI 파이프라인 ``` ### 프로젝트 구조 + ``` SpeechNote/ ├── src/ @@ -894,26 +971,29 @@ SpeechNote/ ## 아키텍처 하이라이트 ### 클린 아키텍처 레이어 -- **애플리케이션 레이어**: 유스케이스 조정 및 도메인 로직 코디네이션 -- **코어 레이어**: 비즈니스 로직 및 변환 서비스 -- **도메인 레이어**: 비즈니스 엔티티 및 도메인 이벤트 -- **인프라 레이어**: 외부 서비스 및 API 통합 -- **UI 레이어**: 사용자 인터페이스 컴포넌트 및 설정 관리 + +- **애플리케이션 레이어**: 유스케이스 조정 및 도메인 로직 코디네이션 +- **코어 레이어**: 비즈니스 로직 및 변환 서비스 +- **도메인 레이어**: 비즈니스 엔티티 및 도메인 이벤트 +- **인프라 레이어**: 외부 서비스 및 API 통합 +- **UI 레이어**: 사용자 인터페이스 컴포넌트 및 설정 관리 ### 사용된 디자인 패턴 -- **팩토리 패턴**: 공급자 인스턴스화를 위한 TranscriberFactory -- **어댑터 패턴**: 공급자 통합을 위한 API 어댑터 -- **옵저버 패턴**: EventManager를 통한 이벤트 기반 아키텍처 -- **전략 패턴**: 다중 변환 공급자 -- **리포지토리 패턴**: 스토리지 관리 추상화 -- **의존성 주입**: 느슨한 결합을 위한 IoC 컨테이너 -- **에러 바운더리 패턴**: 포괄적인 오류 처리 + +- **팩토리 패턴**: 공급자 인스턴스화를 위한 TranscriberFactory +- **어댑터 패턴**: 공급자 통합을 위한 API 어댑터 +- **옵저버 패턴**: EventManager를 통한 이벤트 기반 아키텍처 +- **전략 패턴**: 다중 변환 공급자 +- **리포지토리 패턴**: 스토리지 관리 추상화 +- **의존성 주입**: 느슨한 결합을 위한 IoC 컨테이너 +- **에러 바운더리 패턴**: 포괄적인 오류 처리 ## 기여 기여를 환영합니다! 가이드라인은 [CONTRIBUTING.md](CONTRIBUTING.md)를 참조하세요. ### 기여 과정 + 1. **Fork** 저장소 2. **생성** 기능 브랜치: `git checkout -b feature/amazing-feature` 3. **커밋** 변경사항: `git commit -m 'feat: add amazing feature'` @@ -921,10 +1001,11 @@ SpeechNote/ 5. **생성** 풀 리퀘스트 ### 코드 스타일 가이드라인 -- TypeScript 모범 사례 준수 -- ESLint 및 Prettier 구성 사용 -- 새 기능에 대한 단위 테스트 작성 -- API 변경 사항에 대한 문서 업데이트 + +- TypeScript 모범 사례 준수 +- ESLint 및 Prettier 구성 사용 +- 새 기능에 대한 단위 테스트 작성 +- API 변경 사항에 대한 문서 업데이트 ## 라이선스 @@ -933,58 +1014,67 @@ SpeechNote/ ## 크레딧 ### 감사의 말 -- **Obsidian Team**: Obsidian 플러그인 API -- **OpenAI**: Whisper API -- **Deepgram**: 음성-텍스트 API -- **Community**: Obsidian 커뮤니티 피드백 및 기여 + +- **Obsidian Team**: Obsidian 플러그인 API +- **OpenAI**: Whisper API +- **Deepgram**: 음성-텍스트 API +- **Community**: Obsidian 커뮤니티 피드백 및 기여 ### 사용 기술 -- TypeScript -- ESBuild -- Jest -- ESLint & Prettier + +- TypeScript +- ESBuild +- Jest +- ESLint & Prettier ## 지원 ### 도움이 필요하신가요? -- 🐛 **버그 신고**: [GitHub Issues](https://github.com/asyouplz/SpeechNote/issues) -- 💡 **기능 요청**: [GitHub Issues](https://github.com/asyouplz/SpeechNote/issues) -- 💬 **토론**: [GitHub Discussions](https://github.com/asyouplz/SpeechNote/discussions) + +- 🐛 **버그 신고**: [GitHub Issues](https://github.com/asyouplz/SpeechNote/issues) +- 💡 **기능 요청**: [GitHub Issues](https://github.com/asyouplz/SpeechNote/issues) +- 💬 **토론**: [GitHub Discussions](https://github.com/asyouplz/SpeechNote/discussions) ### 지원 보여주기 + 이 프로젝트가 도움이 되었다면: -- ⭐ GitHub에 별 주기 -- 🐦 소셜 미디어에 공유 -- ☕ [커피 사주기](https://buymeacoffee.com/asyouplz) + +- ⭐ GitHub에 별 주기 +- 🐦 소셜 미디어에 공유 +- ☕ [커피 사주기](https://buymeacoffee.com/asyouplz) ## 최근 업데이트 ### 🚀 v3.0.4 (최신) - 설정 안정화 및 Deepgram 정리 -- 🛠️ **설정 안정화**: 플러그인 설정 신뢰성 개선 -- 🧹 **Deepgram 리팩터링**: 공급자 통합 구조 정리 -- 🧩 **기타 수정**: 소소한 버그 수정 및 개선 + +- 🛠️ **설정 안정화**: 플러그인 설정 신뢰성 개선 +- 🧹 **Deepgram 리팩터링**: 공급자 통합 구조 정리 +- 🧩 **기타 수정**: 소소한 버그 수정 및 개선 ### v3.0.1 - 엔터프라이즈 아키텍처 및 성능 -- 🏗️ **클린 아키텍처**: 관심사 분리를 갖춘 도메인 주도 설계 -- 🎯 **모듈식 구조**: application, core, domain, infrastructure 레이어로 구성 -- ⚡ **성능 모니터링**: 내장 성능 벤치마킹 및 메모리 관리 -- 🛡️ **에러 바운더리**: ErrorBoundary 패턴을 활용한 포괄적인 오류 처리 -- 📦 **의존성 주입**: 더 나은 테스트 가능성을 위한 DependencyContainer -- 🧪 **테스트 커버리지**: 단위, 통합, E2E 테스트 스위트 -- 🔄 **설정 마이그레이션**: 자동 설정 마이그레이션 및 검증 -- 📊 **배치 처리**: 효율적인 배치 요청 관리 + +- 🏗️ **클린 아키텍처**: 관심사 분리를 갖춘 도메인 주도 설계 +- 🎯 **모듈식 구조**: application, core, domain, infrastructure 레이어로 구성 +- ⚡ **성능 모니터링**: 내장 성능 벤치마킹 및 메모리 관리 +- 🛡️ **에러 바운더리**: ErrorBoundary 패턴을 활용한 포괄적인 오류 처리 +- 📦 **의존성 주입**: 더 나은 테스트 가능성을 위한 DependencyContainer +- 🧪 **테스트 커버리지**: 단위, 통합, E2E 테스트 스위트 +- 🔄 **설정 마이그레이션**: 자동 설정 마이그레이션 및 검증 +- 📊 **배치 처리**: 효율적인 배치 요청 관리 ### v3.0.0 - Nova-3 및 화자 분리 -- ✨ **Nova-3 모델**: 98% 정확도로 기본 모델 업그레이드 -- 🎭 **화자 분리**: "Speaker 1:", "Speaker 2:" 형식의 완전한 구현 -- 💰 **비용 최적화**: Deepgram Nova-3로 70% 비용 절감 -- 🔧 **코드 품질**: 향상된 타입 안전성 및 코드 구성 + +- ✨ **Nova-3 모델**: 98% 정확도로 기본 모델 업그레이드 +- 🎭 **화자 분리**: "Speaker 1:", "Speaker 2:" 형식의 완전한 구현 +- 💰 **비용 최적화**: Deepgram Nova-3로 70% 비용 절감 +- 🔧 **코드 품질**: 향상된 타입 안전성 및 코드 구성 ### v1.0.0 - 초기 릴리즈 -- 🎉 **다중 공급자 지원**: OpenAI Whisper 및 Deepgram 통합 -- 🌐 **다국어 지원**: 자동 감지 기능이 있는 40개 이상 언어 -- 📝 **스마트 텍스트 삽입**: 유연한 삽입 옵션 -- 🎯 **컨텍스트 메뉴 통합**: 오른쪽 클릭 변환 + +- 🎉 **다중 공급자 지원**: OpenAI Whisper 및 Deepgram 통합 +- 🌐 **다국어 지원**: 자동 감지 기능이 있는 40개 이상 언어 +- 📝 **스마트 텍스트 삽입**: 유연한 삽입 옵션 +- 🎯 **컨텍스트 메뉴 통합**: 오른쪽 클릭 변환 --- diff --git a/config/deepgram-models.json b/config/deepgram-models.json index 2393cba..a7f88b4 100644 --- a/config/deepgram-models.json +++ b/config/deepgram-models.json @@ -1,188 +1,231 @@ { - "models": { - "nova-3": { - "id": "nova-3", - "name": "Nova-3", - "description": "Next-generation model with state-of-the-art accuracy and enhanced diarization", - "tier": "premium", - "features": { - "punctuation": true, - "smartFormat": true, - "diarization": true, - "numerals": true, - "profanityFilter": true, - "redaction": true, - "utterances": true, - "summarization": true, - "advancedDiarization": true, - "emotionDetection": true, - "speakerIdentification": true - }, - "languages": ["en", "es", "fr", "de", "pt", "nl", "it", "pl", "ru", "zh", "ja", "ko", "ar", "hi", "tr", "sv", "da", "no", "fi", "cs", "hu", "bg"], - "performance": { - "accuracy": 98, - "speed": "fast", - "latency": "low" - }, - "pricing": { - "perMinute": 0.0043, - "currency": "USD" - }, - "isDefault": true, - "migrationPath": { - "from": ["nova-2", "nova"], - "autoMigrate": true, - "backwardCompatible": true - } + "models": { + "nova-3": { + "id": "nova-3", + "name": "Nova-3", + "description": "Next-generation model with state-of-the-art accuracy and enhanced diarization", + "tier": "premium", + "features": { + "punctuation": true, + "smartFormat": true, + "diarization": true, + "numerals": true, + "profanityFilter": true, + "redaction": true, + "utterances": true, + "summarization": true, + "advancedDiarization": true, + "emotionDetection": true, + "speakerIdentification": true + }, + "languages": [ + "en", + "es", + "fr", + "de", + "pt", + "nl", + "it", + "pl", + "ru", + "zh", + "ja", + "ko", + "ar", + "hi", + "tr", + "sv", + "da", + "no", + "fi", + "cs", + "hu", + "bg" + ], + "performance": { + "accuracy": 98, + "speed": "fast", + "latency": "low" + }, + "pricing": { + "perMinute": 0.0043, + "currency": "USD" + }, + "isDefault": true, + "migrationPath": { + "from": ["nova-2", "nova"], + "autoMigrate": true, + "backwardCompatible": true + } + }, + "nova-2": { + "id": "nova-2", + "name": "Nova-2", + "description": "Latest and most powerful model with best accuracy", + "tier": "premium", + "features": { + "punctuation": true, + "smartFormat": true, + "diarization": true, + "numerals": true, + "profanityFilter": true, + "redaction": true, + "utterances": true, + "summarization": true + }, + "languages": [ + "en", + "es", + "fr", + "de", + "pt", + "nl", + "it", + "pl", + "ru", + "zh", + "ja", + "ko", + "ar", + "hi", + "tr", + "sv", + "da", + "no", + "fi" + ], + "performance": { + "accuracy": 95, + "speed": "fast", + "latency": "low" + }, + "pricing": { + "perMinute": 0.0145, + "currency": "USD" + } + }, + "nova": { + "id": "nova", + "name": "Nova", + "description": "High-quality model with excellent accuracy", + "tier": "standard", + "features": { + "punctuation": true, + "smartFormat": true, + "diarization": true, + "numerals": true, + "profanityFilter": true, + "redaction": false, + "utterances": true, + "summarization": false + }, + "languages": ["en", "es", "fr", "de", "pt", "nl", "it", "pl", "ru", "zh", "ja", "ko"], + "performance": { + "accuracy": 90, + "speed": "fast", + "latency": "low" + }, + "pricing": { + "perMinute": 0.0125, + "currency": "USD" + } + }, + "enhanced": { + "id": "enhanced", + "name": "Enhanced", + "description": "Balanced model for general use", + "tier": "basic", + "features": { + "punctuation": true, + "smartFormat": true, + "diarization": false, + "numerals": true, + "profanityFilter": true, + "redaction": false, + "utterances": false, + "summarization": false + }, + "languages": ["en", "es", "fr", "de", "pt"], + "performance": { + "accuracy": 85, + "speed": "moderate", + "latency": "medium" + }, + "pricing": { + "perMinute": 0.0085, + "currency": "USD" + } + }, + "base": { + "id": "base", + "name": "Base", + "description": "Basic model for simple transcription", + "tier": "economy", + "features": { + "punctuation": true, + "smartFormat": false, + "diarization": false, + "numerals": false, + "profanityFilter": false, + "redaction": false, + "utterances": false, + "summarization": false + }, + "languages": ["en"], + "performance": { + "accuracy": 80, + "speed": "moderate", + "latency": "medium" + }, + "pricing": { + "perMinute": 0.0059, + "currency": "USD" + } + } }, - "nova-2": { - "id": "nova-2", - "name": "Nova-2", - "description": "Latest and most powerful model with best accuracy", - "tier": "premium", - "features": { - "punctuation": true, - "smartFormat": true, - "diarization": true, - "numerals": true, - "profanityFilter": true, - "redaction": true, - "utterances": true, - "summarization": true - }, - "languages": ["en", "es", "fr", "de", "pt", "nl", "it", "pl", "ru", "zh", "ja", "ko", "ar", "hi", "tr", "sv", "da", "no", "fi"], - "performance": { - "accuracy": 95, - "speed": "fast", - "latency": "low" - }, - "pricing": { - "perMinute": 0.0145, - "currency": "USD" - } - }, - "nova": { - "id": "nova", - "name": "Nova", - "description": "High-quality model with excellent accuracy", - "tier": "standard", - "features": { - "punctuation": true, - "smartFormat": true, - "diarization": true, - "numerals": true, - "profanityFilter": true, - "redaction": false, - "utterances": true, - "summarization": false - }, - "languages": ["en", "es", "fr", "de", "pt", "nl", "it", "pl", "ru", "zh", "ja", "ko"], - "performance": { - "accuracy": 90, - "speed": "fast", - "latency": "low" - }, - "pricing": { - "perMinute": 0.0125, - "currency": "USD" - } - }, - "enhanced": { - "id": "enhanced", - "name": "Enhanced", - "description": "Balanced model for general use", - "tier": "basic", - "features": { - "punctuation": true, - "smartFormat": true, - "diarization": false, - "numerals": true, - "profanityFilter": true, - "redaction": false, - "utterances": false, - "summarization": false - }, - "languages": ["en", "es", "fr", "de", "pt"], - "performance": { - "accuracy": 85, - "speed": "moderate", - "latency": "medium" - }, - "pricing": { - "perMinute": 0.0085, - "currency": "USD" - } - }, - "base": { - "id": "base", - "name": "Base", - "description": "Basic model for simple transcription", - "tier": "economy", - "features": { - "punctuation": true, - "smartFormat": false, - "diarization": false, - "numerals": false, - "profanityFilter": false, - "redaction": false, - "utterances": false, - "summarization": false - }, - "languages": ["en"], - "performance": { - "accuracy": 80, - "speed": "moderate", - "latency": "medium" - }, - "pricing": { - "perMinute": 0.0059, - "currency": "USD" - } + "features": { + "punctuation": { + "name": "Punctuation", + "description": "Add punctuation marks to transcript", + "default": true + }, + "smartFormat": { + "name": "Smart Format", + "description": "Apply intelligent formatting (numbers, dates, etc.)", + "default": true + }, + "diarization": { + "name": "Speaker Diarization", + "description": "Identify different speakers in audio", + "default": true, + "requiresPremium": true + }, + "numerals": { + "name": "Numerals", + "description": "Convert number words to digits", + "default": true + }, + "profanityFilter": { + "name": "Profanity Filter", + "description": "Filter profane words", + "default": false + }, + "redaction": { + "name": "Redaction", + "description": "Redact sensitive information (SSN, credit cards)", + "default": false, + "requiresPremium": true + }, + "utterances": { + "name": "Utterances", + "description": "Split transcript by natural speech boundaries", + "default": false + }, + "summarization": { + "name": "Summarization", + "description": "Generate summary of transcript", + "default": false, + "requiresPremium": true + } } - }, - "features": { - "punctuation": { - "name": "Punctuation", - "description": "Add punctuation marks to transcript", - "default": true - }, - "smartFormat": { - "name": "Smart Format", - "description": "Apply intelligent formatting (numbers, dates, etc.)", - "default": true - }, - "diarization": { - "name": "Speaker Diarization", - "description": "Identify different speakers in audio", - "default": true, - "requiresPremium": true - }, - "numerals": { - "name": "Numerals", - "description": "Convert number words to digits", - "default": true - }, - "profanityFilter": { - "name": "Profanity Filter", - "description": "Filter profane words", - "default": false - }, - "redaction": { - "name": "Redaction", - "description": "Redact sensitive information (SSN, credit cards)", - "default": false, - "requiresPremium": true - }, - "utterances": { - "name": "Utterances", - "description": "Split transcript by natural speech boundaries", - "default": false - }, - "summarization": { - "name": "Summarization", - "description": "Generate summary of transcript", - "default": false, - "requiresPremium": true - } - } -} \ No newline at end of file +} diff --git a/docs/BRANCH_PROTECTION.md b/docs/BRANCH_PROTECTION.md index 907ec6d..dfd139c 100644 --- a/docs/BRANCH_PROTECTION.md +++ b/docs/BRANCH_PROTECTION.md @@ -5,10 +5,11 @@ GitHub repository의 main 브랜치를 보호하기 위한 설정 가이드입 ## 왜 Branch Protection이 필요한가? Branch Protection은 다음을 보장합니다: -- ✅ 모든 코드가 리뷰를 거쳐 merge됨 -- ✅ CI 파이프라인 통과 없이는 merge 불가 -- ✅ 직접 main 브랜치에 push 방지 -- ✅ 코드 품질 유지 + +- ✅ 모든 코드가 리뷰를 거쳐 merge됨 +- ✅ CI 파이프라인 통과 없이는 merge 불가 +- ✅ 직접 main 브랜치에 push 방지 +- ✅ 코드 품질 유지 ## 설정 방법 @@ -28,95 +29,106 @@ Branch Protection은 다음을 보장합니다: 다음 옵션들을 체크: #### Required (필수) -- ✅ **Require a pull request before merging** - - ✅ "Require approvals" 체크 - - Approvals 수: `1` 입력 - -- ✅ **Require status checks to pass before merging** - - ✅ "Require branches to be up to date before merging" 체크 - - 검색창에서 다음 status checks 추가: - - `Code Quality Check` - - `Build Test` - - > **참고**: Status checks는 최소 한 번 이상 CI가 실행된 후에야 선택 가능합니다. - > PR을 하나 생성하고 CI를 실행한 후 이 설정을 추가하세요. + +- ✅ **Require a pull request before merging** + - ✅ "Require approvals" 체크 + - Approvals 수: `1` 입력 +- ✅ **Require status checks to pass before merging** + + - ✅ "Require branches to be up to date before merging" 체크 + - 검색창에서 다음 status checks 추가: + - `Code Quality Check` + - `Build Test` + + > **참고**: Status checks는 최소 한 번 이상 CI가 실행된 후에야 선택 가능합니다. + > PR을 하나 생성하고 CI를 실행한 후 이 설정을 추가하세요. #### Optional but Recommended (선택적, 권장) -- ✅ **Do not allow bypassing the above settings** - - 관리자도 규칙을 우회할 수 없도록 강제 -- ✅ **Require conversation resolution before merging** - - PR 코멘트가 모두 resolved되어야 merge 가능 +- ✅ **Do not allow bypassing the above settings** -- ✅ **Lock branch** - - main 브랜치를 read-only로 만들기 (선택적) + - 관리자도 규칙을 우회할 수 없도록 강제 + +- ✅ **Require conversation resolution before merging** + + - PR 코멘트가 모두 resolved되어야 merge 가능 + +- ✅ **Lock branch** + - main 브랜치를 read-only로 만들기 (선택적) ### 4. 설정 저장 -- 페이지 하단의 **"Create"** 버튼 클릭 +- 페이지 하단의 **"Create"** 버튼 클릭 ## 설정 검증 Branch Protection이 올바르게 설정되었는지 확인: 1. **새 PR 생성**: - ```bash - git checkout -b test-branch - echo "test" >> test.txt - git add test.txt - git commit -m "test: branch protection" - git push origin test-branch - ``` + + ```bash + git checkout -b test-branch + echo "test" >> test.txt + git add test.txt + git commit -m "test: branch protection" + git push origin test-branch + ``` 2. **GitHub에서 PR 생성** 3. **다음 사항 확인**: - - [ ] CI가 자동으로 실행됨 - - [ ] CI 통과 전에는 merge 버튼이 비활성화됨 - - [ ] "Merge" 대신 "Merge when checks pass" 표시 - - [ ] 1명의 approval 필요 표시 + - [ ] CI가 자동으로 실행됨 + - [ ] CI 통과 전에는 merge 버튼이 비활성화됨 + - [ ] "Merge" 대신 "Merge when checks pass" 표시 + - [ ] 1명의 approval 필요 표시 ## Status Checks가 안 보일 때 Status checks는 CI가 최소 한 번 실행된 후에만 선택 가능합니다: 1. **먼저 PR 생성하고 CI 실행**: - - 아무 변경사항으로 PR을 생성 - - CI가 완료될 때까지 대기 + + - 아무 변경사항으로 PR을 생성 + - CI가 완료될 때까지 대기 2. **다시 Branch Protection 설정으로 이동**: - - Settings → Branches → Edit rule - - "Require status checks to pass" 섹션에서 - - 검색창에 체크할 항목들이 나타남 + + - Settings → Branches → Edit rule + - "Require status checks to pass" 섹션에서 + - 검색창에 체크할 항목들이 나타남 3. **필요한 checks 선택**: - - `Code Quality Check` - - `Build Test` + - `Code Quality Check` + - `Build Test` ## Troubleshooting ### "Settings" 탭이 안 보여요 -- Repository의 owner 또는 admin 권한이 필요합니다 -- Collaborator라면 owner에게 admin 권한을 요청하세요 + +- Repository의 owner 또는 admin 권한이 필요합니다 +- Collaborator라면 owner에게 admin 권한을 요청하세요 ### Status checks를 추가할 수 없어요 -- CI 워크플로우가 최소 한 번 실행되어야 합니다 -- PR을 생성하고 CI가 완료된 후 다시 시도하세요 + +- CI 워크플로우가 최소 한 번 실행되어야 합니다 +- PR을 생성하고 CI가 완료된 후 다시 시도하세요 ### CI를 우회하고 싶어요 -- **권장하지 않습니다**. Branch protection의 목적을 훼손합니다 -- 긴급 hotfix가 필요한 경우: - 1. "Allow specified actors to bypass required pull requests" 체크 - 2. 특정 사용자/팀만 우회 허용 (최소한으로 제한) + +- **권장하지 않습니다**. Branch protection의 목적을 훼손합니다 +- 긴급 hotfix가 필요한 경우: + 1. "Allow specified actors to bypass required pull requests" 체크 + 2. 특정 사용자/팀만 우회 허용 (최소한으로 제한) ## 다음 단계 Branch Protection 설정 후: + 1. ✅ 팀원들에게 새로운 워크플로우 안내 2. ✅ [docs/RELEASE.md](RELEASE.md) 참조하여 릴리즈 프로세스 숙지 3. ✅ Pre-commit hooks 설정: `npm install` ## 참고 자료 -- [GitHub Branch Protection 공식 문서](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) -- [Status Checks 설정](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks) +- [GitHub Branch Protection 공식 문서](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) +- [Status Checks 설정](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks) diff --git a/docs/LINT_RULES.md b/docs/LINT_RULES.md index 6a7f0dc..a3727cb 100644 --- a/docs/LINT_RULES.md +++ b/docs/LINT_RULES.md @@ -29,18 +29,18 @@ async 함수는 반드시 `await` 표현식을 포함해야 합니다. ```typescript // ❌ 잘못된 예 async function loadSettings() { - return this.settings; + return this.settings; } // ✅ 올바른 예 async function loadSettings() { - const data = await this.loadData(); - return data || DEFAULT_SETTINGS; + const data = await this.loadData(); + return data || DEFAULT_SETTINGS; } // ✅ 또는 async 제거 function loadSettings() { - return this.settings; + return this.settings; } ``` @@ -69,17 +69,17 @@ const element = document.createElement('div'); ```typescript // ❌ 잘못된 예 function processData(data: {}) { - // ... + // ... } // ✅ 올바른 예 function processData(data: object) { - // ... + // ... } // 또는 더 명확하게 function processData(data: unknown) { - // ... + // ... } ``` @@ -100,8 +100,8 @@ await someAsyncFunction(); void someAsyncFunction(); // 또는 catch 처리 -someAsyncFunction().catch(error => { - console.error('Error:', error); +someAsyncFunction().catch((error) => { + console.error('Error:', error); }); ``` @@ -109,8 +109,8 @@ someAsyncFunction().catch(error => { Obsidian API의 deprecated 항목은 사용하지 않아야 합니다: -- `noticeEl` → `messageEl` 사용 -- `createElement` → Obsidian의 `createEl` 또는 `createDiv` 사용 +- `noticeEl` → `messageEl` 사용 +- `createElement` → Obsidian의 `createEl` 또는 `createDiv` 사용 ```typescript // ❌ 잘못된 예 @@ -133,14 +133,14 @@ UI 텍스트는 sentence case를 사용해야 합니다 (첫 글자만 대문자 ```typescript // ❌ 잘못된 예 addCommand({ - id: 'my-command', - name: 'Convert Audio To Text', // Title Case + id: 'my-command', + name: 'Convert Audio To Text', // Title Case }); // ✅ 올바른 예 addCommand({ - id: 'my-command', - name: 'Convert audio to text', // Sentence case + id: 'my-command', + name: 'Convert audio to text', // Sentence case }); ``` @@ -150,9 +150,9 @@ addCommand({ 샘플 코드나 placeholder는 제거해야 합니다: -- `MyPlugin` → 실제 플러그인 이름으로 변경 -- `SampleSettingTab` → 실제 이름으로 변경 -- 샘플 커맨드 제거 +- `MyPlugin` → 실제 플러그인 이름으로 변경 +- `SampleSettingTab` → 실제 이름으로 변경 +- 샘플 커맨드 제거 ### HTML Heading 금지 @@ -165,9 +165,7 @@ Settings 탭에서 HTML heading 태그를 직접 사용하지 않아야 합니 containerEl.createEl('h2', { text: 'General Settings' }); // ✅ 올바른 예 -new Setting(containerEl) - .setName('General settings') - .setHeading(); +new Setting(containerEl).setName('General settings').setHeading(); ``` ## Optional 규칙 (경고) @@ -188,7 +186,7 @@ underscore(`_`)로 시작하는 변수는 미사용으로 허용됩니다: // ✅ 허용됨 const _unusedVariable = someValue; function handler(_event: Event) { - // event를 사용하지 않지만 signature가 필요한 경우 + // event를 사용하지 않지만 signature가 필요한 경우 } ``` @@ -202,6 +200,6 @@ function handler(_event: Event) { ## 추가 리소스 -- [Obsidian Plugin Guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines) -- [eslint-plugin-obsidian](https://github.com/obsidianmd/eslint-plugin) -- [Obsidian Developer Docs](https://docs.obsidian.md/) +- [Obsidian Plugin Guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines) +- [eslint-plugin-obsidian](https://github.com/obsidianmd/eslint-plugin) +- [Obsidian Developer Docs](https://docs.obsidian.md/) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index df28d0f..aebbd94 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -5,51 +5,63 @@ ## English ### Data flow summary -- Audio files are sent to the transcription provider you select (OpenAI Whisper or Deepgram). -- Transcription results are written into your Obsidian notes. -- API keys are stored in Obsidian settings on your device. + +- Audio files are sent to the transcription provider you select (OpenAI Whisper or Deepgram). +- Transcription results are written into your Obsidian notes. +- API keys are stored in Obsidian settings on your device. ### What is shared with providers -- Audio content and basic request metadata needed for transcription. -- Any optional features enabled in settings may affect what is sent (for example, diarization). + +- Audio content and basic request metadata needed for transcription. +- Any optional features enabled in settings may affect what is sent (for example, diarization). ### Local storage -- Settings are stored locally in your Obsidian configuration. -- If the plugin caches results or metadata, those remain on your device. + +- Settings are stored locally in your Obsidian configuration. +- If the plugin caches results or metadata, those remain on your device. ### Logs and diagnostics -- If you enable detailed logs, avoid sharing files that may contain sensitive data. -- Do not paste API keys into public issue trackers. + +- If you enable detailed logs, avoid sharing files that may contain sensitive data. +- Do not paste API keys into public issue trackers. ### Your responsibilities -- Review the privacy policies of OpenAI and Deepgram for how they handle data. -- Rotate keys if you believe they were exposed. + +- Review the privacy policies of OpenAI and Deepgram for how they handle data. +- Rotate keys if you believe they were exposed. ### Contact + For privacy questions, open a GitHub Issue and label it "privacy". ## 한국어 ### 데이터 흐름 요약 -- 오디오 파일은 선택한 변환 공급자(OpenAI Whisper 또는 Deepgram)로 전송됩니다. -- 변환 결과는 Obsidian 노트에 저장됩니다. -- API 키는 사용자 기기의 Obsidian 설정에 저장됩니다. + +- 오디오 파일은 선택한 변환 공급자(OpenAI Whisper 또는 Deepgram)로 전송됩니다. +- 변환 결과는 Obsidian 노트에 저장됩니다. +- API 키는 사용자 기기의 Obsidian 설정에 저장됩니다. ### 공급자에게 전송되는 정보 -- 변환을 위해 필요한 오디오 데이터와 기본 요청 메타데이터. -- 설정에서 선택한 옵션(예: 화자 분리)에 따라 전송 정보가 달라질 수 있습니다. + +- 변환을 위해 필요한 오디오 데이터와 기본 요청 메타데이터. +- 설정에서 선택한 옵션(예: 화자 분리)에 따라 전송 정보가 달라질 수 있습니다. ### 로컬 저장 -- 설정은 Obsidian 구성에 로컬로 저장됩니다. -- 결과나 메타데이터를 캐시하는 경우, 해당 데이터는 기기 내에만 유지됩니다. + +- 설정은 Obsidian 구성에 로컬로 저장됩니다. +- 결과나 메타데이터를 캐시하는 경우, 해당 데이터는 기기 내에만 유지됩니다. ### 로그 및 진단 -- 상세 로그를 사용하는 경우 민감한 정보가 포함되지 않도록 주의하세요. -- API 키를 공개 이슈에 공유하지 마세요. + +- 상세 로그를 사용하는 경우 민감한 정보가 포함되지 않도록 주의하세요. +- API 키를 공개 이슈에 공유하지 마세요. ### 사용자 책임 -- OpenAI와 Deepgram의 개인정보 처리방침을 확인하세요. -- 키가 노출되었다고 의심되면 키를 교체하세요. + +- OpenAI와 Deepgram의 개인정보 처리방침을 확인하세요. +- 키가 노출되었다고 의심되면 키를 교체하세요. ### 문의 + 개인정보 관련 문의는 GitHub Issue에 "privacy" 라벨로 등록해 주세요. diff --git a/docs/README.md b/docs/README.md index 3eb80fe..256282a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,11 +5,13 @@ ## English ### Overview + SpeechNote is an Obsidian plugin that transcribes audio files into notes. You can use OpenAI Whisper or Deepgram as the transcription provider. Some features (such as speaker diarization) depend on the selected provider and settings. ### Quick start + 1. Install the plugin from Obsidian Community Plugins (recommended), or install manually from the GitHub release files. 2. Open Obsidian Settings and select Speech to Text. @@ -17,30 +19,36 @@ speaker diarization) depend on the selected provider and settings. 4. Run the command "Transcribe audio file" from the command palette or file context menu. ### Documentation map -- Settings: see SETTINGS.md for configuration options and recommendations. -- Troubleshooting: see TROUBLESHOOTING.md for common issues and fixes. -- Privacy: see PRIVACY.md for data flow and privacy considerations. + +- Settings: see SETTINGS.md for configuration options and recommendations. +- Troubleshooting: see TROUBLESHOOTING.md for common issues and fixes. +- Privacy: see PRIVACY.md for data flow and privacy considerations. ### Support + If you need help, use GitHub Issues for bugs and GitHub Discussions for questions. ## 한국어 ### 개요 + SpeechNote는 Obsidian에서 오디오 파일을 텍스트로 변환하는 플러그인입니다. OpenAI Whisper 또는 Deepgram을 변환 공급자로 사용할 수 있습니다. 화자 분리처럼 일부 기능은 공급자와 설정에 따라 달라질 수 있습니다. ### 빠른 시작 + 1. Obsidian 커뮤니티 플러그인에서 설치하는 것을 권장합니다. 2. Obsidian 설정에서 Speech to Text를 선택합니다. 3. 사용할 공급자의 API 키를 입력합니다. 4. 명령 팔레트 또는 파일 컨텍스트 메뉴에서 "Transcribe audio file"을 실행합니다. ### 문서 안내 -- 설정: SETTINGS.md에서 옵션과 권장 설정을 확인하세요. -- 문제 해결: TROUBLESHOOTING.md에서 흔한 문제와 해결 방법을 확인하세요. -- 개인정보: PRIVACY.md에서 데이터 흐름과 개인정보 관련 안내를 확인하세요. + +- 설정: SETTINGS.md에서 옵션과 권장 설정을 확인하세요. +- 문제 해결: TROUBLESHOOTING.md에서 흔한 문제와 해결 방법을 확인하세요. +- 개인정보: PRIVACY.md에서 데이터 흐름과 개인정보 관련 안내를 확인하세요. ### 지원 + 버그는 GitHub Issues, 질문은 GitHub Discussions를 이용해 주세요. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 5bef3ed..ea3e9e8 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -7,16 +7,18 @@ ### 버전 형식 Semantic Versioning (x.y.z) 사용: -- **Major (x)**: Breaking changes -- **Minor (y)**: 새로운 기능 추가 (하위 호환) -- **Patch (z)**: 버그 수정 + +- **Major (x)**: Breaking changes +- **Minor (y)**: 새로운 기능 추가 (하위 호환) +- **Patch (z)**: 버그 수정 ### 버전 관리 파일 다음 파일들이 버전을 포함하고 있으며, 버전 bump 시 자동으로 업데이트됩니다: -- `manifest.json` - Obsidian 플러그인 버전 -- `package.json` - npm 패키지 버전 -- `versions.json` - Obsidian 버전 호환성 정보 + +- `manifest.json` - Obsidian 플러그인 버전 +- `package.json` - npm 패키지 버전 +- `versions.json` - Obsidian 버전 호환성 정보 ## 릴리즈 방법 @@ -26,9 +28,9 @@ PR에 라벨을 추가하여 merge 시 자동으로 버전이 증가합니다. 1. PR 생성 2. 다음 라벨 중 하나 추가: - - `bump:patch` - 3.0.9 → 3.0.10 - - `bump:minor` - 3.0.9 → 3.1.0 - - `bump:major` - 3.0.9 → 4.0.0 + - `bump:patch` - 3.0.9 → 3.0.10 + - `bump:minor` - 3.0.9 → 3.1.0 + - `bump:major` - 3.0.9 → 4.0.0 3. PR merge 4. 자동으로 버전 증가 및 tag 생성 5. Tag push로 Release 워크플로우 자동 실행 @@ -52,6 +54,7 @@ PR에 라벨을 추가하여 merge 시 자동으로 버전이 증가합니다. ``` 스크립트는 다음을 자동으로 수행합니다: + 1. ✅ 현재 브랜치 확인 (main 권장) 2. ✅ 최신 코드 pull 3. ✅ Lint, TypeScript, Build 체크 @@ -92,7 +95,7 @@ PR에 라벨을 추가하여 merge 시 자동으로 버전이 증가합니다. ### Release Pipeline (Tag push 시 자동 실행) -버전 tag (v*.*.*)가 push되면 자동으로 릴리즈 생성: +버전 tag (v*.*.\*)가 push되면 자동으로 릴리즈 생성: ``` 1. Validate Release @@ -127,6 +130,7 @@ PR에 라벨을 추가하여 merge 시 자동으로 버전이 증가합니다. ``` 설치 후 자동으로 활성화됩니다: + ```bash npm install # prepare 스크립트가 자동으로 husky 설정 ``` @@ -135,23 +139,23 @@ npm install # prepare 스크립트가 자동으로 husky 설정 main 브랜치 보호 규칙 (GitHub Settings에서 설정 권장): -- ✅ Require pull request before merging -- ✅ Require status checks to pass: - - `Code Quality Check` - - `Build Test` -- ✅ Require branches to be up to date -- ✅ Do not allow bypassing the above settings +- ✅ Require pull request before merging +- ✅ Require status checks to pass: + - `Code Quality Check` + - `Build Test` +- ✅ Require branches to be up to date +- ✅ Do not allow bypassing the above settings ## 릴리즈 체크리스트 릴리즈 전 확인사항: -- [ ] 모든 코드 변경사항이 commit됨 -- [ ] CI 파이프라인 통과 -- [ ] CHANGELOG 또는 release notes 작성 -- [ ] 버전 번호가 Semantic Versioning 준수 -- [ ] Breaking changes가 있다면 문서화됨 -- [ ] Obsidian 최소 버전 호환성 확인 +- [ ] 모든 코드 변경사항이 commit됨 +- [ ] CI 파이프라인 통과 +- [ ] CHANGELOG 또는 release notes 작성 +- [ ] 버전 번호가 Semantic Versioning 준수 +- [ ] Breaking changes가 있다면 문서화됨 +- [ ] Obsidian 최소 버전 호환성 확인 ## 트러블슈팅 @@ -160,6 +164,7 @@ main 브랜치 보호 규칙 (GitHub Settings에서 설정 권장): **문제**: `version-bump.yml` 워크플로우가 실패함 **해결**: + 1. PR에 올바른 라벨이 있는지 확인 (`bump:patch`, `bump:minor`, `bump:major`) 2. GitHub Actions 권한 확인 (Settings → Actions → Workflow permissions) 3. `manifest.json`, `package.json`, `versions.json` 형식 확인 @@ -169,6 +174,7 @@ main 브랜치 보호 규칙 (GitHub Settings에서 설정 권장): **문제**: Tag는 생성되었으나 Release가 생성되지 않음 **해결**: + 1. [Release 워크플로우 로그](https://github.com/asyouplz/SpeechNote/actions/workflows/release.yml) 확인 2. Tag 형식이 `v0.0.0` 형식인지 확인 3. Build 실패 시 lint/typecheck 오류 확인 @@ -178,6 +184,7 @@ main 브랜치 보호 규칙 (GitHub Settings에서 설정 권장): **문제**: Commit 시 lint가 자동으로 실행되지 않음 **해결**: + ```bash # Husky 재설정 npm run prepare @@ -189,7 +196,7 @@ chmod +x .husky/pre-commit ## 참고 자료 -- [Semantic Versioning](https://semver.org/) -- [Obsidian Plugin Guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines) -- [GitHub Actions Documentation](https://docs.github.com/en/actions) -- [Husky Documentation](https://typicode.github.io/husky/) +- [Semantic Versioning](https://semver.org/) +- [Obsidian Plugin Guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines) +- [GitHub Actions Documentation](https://docs.github.com/en/actions) +- [Husky Documentation](https://typicode.github.io/husky/) diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index feda39a..a161ea3 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -5,85 +5,105 @@ ## English ### Notes about settings -- Available settings may vary by version and provider. -- If you do not see a setting in your UI, it may be hidden, unavailable, or disabled. + +- Available settings may vary by version and provider. +- If you do not see a setting in your UI, it may be hidden, unavailable, or disabled. ### Provider and API keys -- Provider: Auto, OpenAI Whisper, or Deepgram. -- API keys: add the key for the provider you want to use. + +- Provider: Auto, OpenAI Whisper, or Deepgram. +- API keys: add the key for the provider you want to use. ### Language -- Auto-detect is recommended unless you always use a single language. + +- Auto-detect is recommended unless you always use a single language. ### Insert position -- Insert at cursor, at the top of the note, or at the bottom of the note. -- Auto-insert can place the result immediately after transcription completes. + +- Insert at cursor, at the top of the note, or at the bottom of the note. +- Auto-insert can place the result immediately after transcription completes. ### Speaker diarization (Deepgram) -- Enable diarization to label speakers in multi-person recordings. -- If you only have a single speaker, leave it off for simpler output. + +- Enable diarization to label speakers in multi-person recordings. +- If you only have a single speaker, leave it off for simpler output. ### Text formatting -- Output can be plain text or formatted (for example, markdown or blocks). -- Choose a format that matches your note-taking style. + +- Output can be plain text or formatted (for example, markdown or blocks). +- Choose a format that matches your note-taking style. ### Performance and reliability -- Timeouts and retry limits help when networks are unstable. -- Concurrency and batching (if available) can improve throughput for multiple files. + +- Timeouts and retry limits help when networks are unstable. +- Concurrency and batching (if available) can improve throughput for multiple files. ### Large files -- Some providers have file size limits. Use chunking or compression if available. -- For large files, Deepgram is often more suitable than Whisper. + +- Some providers have file size limits. Use chunking or compression if available. +- For large files, Deepgram is often more suitable than Whisper. ### Cost controls -- If your version exposes budget or cost controls, set a safe limit to avoid surprises. + +- If your version exposes budget or cost controls, set a safe limit to avoid surprises. ### Recommended baseline -- Provider: Auto -- Language: Auto -- Insert position: Cursor -- Diarization: Off for single speaker, On for meetings + +- Provider: Auto +- Language: Auto +- Insert position: Cursor +- Diarization: Off for single speaker, On for meetings ## 한국어 ### 설정 안내 -- 설정 항목은 버전과 공급자에 따라 달라질 수 있습니다. -- UI에서 보이지 않는 항목은 비활성화되었거나 제공되지 않는 항목일 수 있습니다. + +- 설정 항목은 버전과 공급자에 따라 달라질 수 있습니다. +- UI에서 보이지 않는 항목은 비활성화되었거나 제공되지 않는 항목일 수 있습니다. ### 공급자 및 API 키 -- 공급자: Auto, OpenAI Whisper, Deepgram 중 선택합니다. -- 사용할 공급자의 API 키를 입력합니다. + +- 공급자: Auto, OpenAI Whisper, Deepgram 중 선택합니다. +- 사용할 공급자의 API 키를 입력합니다. ### 언어 -- 특별한 이유가 없다면 자동 감지를 권장합니다. + +- 특별한 이유가 없다면 자동 감지를 권장합니다. ### 삽입 위치 -- 커서 위치, 노트 상단, 노트 하단 중 선택할 수 있습니다. -- 자동 삽입을 켜면 변환 완료 시 자동으로 삽입됩니다. + +- 커서 위치, 노트 상단, 노트 하단 중 선택할 수 있습니다. +- 자동 삽입을 켜면 변환 완료 시 자동으로 삽입됩니다. ### 화자 분리 (Deepgram) -- 화자 분리를 켜면 다중 화자를 구분해 출력합니다. -- 1인 화자인 경우 끄는 것이 더 깔끔합니다. + +- 화자 분리를 켜면 다중 화자를 구분해 출력합니다. +- 1인 화자인 경우 끄는 것이 더 깔끔합니다. ### 텍스트 포맷 -- 일반 텍스트 또는 포맷된 텍스트(예: 마크다운, 블록)로 출력할 수 있습니다. -- 메모 스타일에 맞는 포맷을 선택하세요. + +- 일반 텍스트 또는 포맷된 텍스트(예: 마크다운, 블록)로 출력할 수 있습니다. +- 메모 스타일에 맞는 포맷을 선택하세요. ### 성능 및 안정성 -- 네트워크가 불안정할 때 타임아웃과 재시도 옵션이 도움이 됩니다. -- 동시 처리나 배치 처리가 있는 경우 여러 파일에 유리합니다. + +- 네트워크가 불안정할 때 타임아웃과 재시도 옵션이 도움이 됩니다. +- 동시 처리나 배치 처리가 있는 경우 여러 파일에 유리합니다. ### 대용량 파일 -- 공급자마다 파일 크기 제한이 다를 수 있습니다. -- 필요하면 청킹 또는 압축 기능을 사용하세요. -- 대용량 파일은 Deepgram이 더 적합한 경우가 많습니다. + +- 공급자마다 파일 크기 제한이 다를 수 있습니다. +- 필요하면 청킹 또는 압축 기능을 사용하세요. +- 대용량 파일은 Deepgram이 더 적합한 경우가 많습니다. ### 비용 관리 -- 버전에 따라 예산/비용 한도 옵션이 제공될 수 있습니다. -- 안전한 한도를 설정해 예기치 않은 비용을 줄이세요. + +- 버전에 따라 예산/비용 한도 옵션이 제공될 수 있습니다. +- 안전한 한도를 설정해 예기치 않은 비용을 줄이세요. ### 권장 기본값 -- 공급자: Auto -- 언어: 자동 감지 -- 삽입 위치: 커서 위치 -- 화자 분리: 1인 오디오에는 Off, 회의에는 On + +- 공급자: Auto +- 언어: 자동 감지 +- 삽입 위치: 커서 위치 +- 화자 분리: 1인 오디오에는 Off, 회의에는 On diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a94fe6c..6ebc410 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -5,67 +5,81 @@ ## English ### "Invalid API Key" -- Confirm the key is correct and has no extra spaces. -- Check the provider dashboard for key status and billing. -- If you changed keys, restart Obsidian to reload settings. + +- Confirm the key is correct and has no extra spaces. +- Check the provider dashboard for key status and billing. +- If you changed keys, restart Obsidian to reload settings. ### "File too large" -- Whisper has smaller limits; Deepgram supports larger files. -- Use compression or chunking if available. + +- Whisper has smaller limits; Deepgram supports larger files. +- Use compression or chunking if available. ### No audio files found -- Ensure the file is inside your vault. -- Check supported formats (for example: m4a, mp3, wav, mp4). -- Wait for Obsidian indexing to complete. + +- Ensure the file is inside your vault. +- Check supported formats (for example: m4a, mp3, wav, mp4). +- Wait for Obsidian indexing to complete. ### Empty or partial output -- Try a different provider or enable retries. -- Use clearer audio and avoid overlapping speech. + +- Try a different provider or enable retries. +- Use clearer audio and avoid overlapping speech. ### Network errors or timeouts -- Check your internet connection. -- Increase request timeout if available. -- Retry later if the provider is rate-limiting. + +- Check your internet connection. +- Increase request timeout if available. +- Retry later if the provider is rate-limiting. ### Speaker diarization not working -- Make sure diarization is enabled in settings. -- Use a model that supports diarization. -- Ensure each speaker has enough uninterrupted time. + +- Make sure diarization is enabled in settings. +- Use a model that supports diarization. +- Ensure each speaker has enough uninterrupted time. ### Still stuck? -- Reproduce the issue and capture logs if the plugin provides them. -- Open a GitHub Issue with steps, expected behavior, and actual behavior. + +- Reproduce the issue and capture logs if the plugin provides them. +- Open a GitHub Issue with steps, expected behavior, and actual behavior. ## 한국어 ### "Invalid API Key" -- 키에 공백이 없는지 확인합니다. -- 공급자 대시보드에서 키 상태와 결제 상태를 확인합니다. -- 키를 변경했다면 Obsidian을 재시작해 설정을 다시 불러옵니다. + +- 키에 공백이 없는지 확인합니다. +- 공급자 대시보드에서 키 상태와 결제 상태를 확인합니다. +- 키를 변경했다면 Obsidian을 재시작해 설정을 다시 불러옵니다. ### "File too large" -- Whisper는 제한이 더 작고, Deepgram은 더 큰 파일을 지원합니다. -- 가능하다면 압축이나 청킹을 사용합니다. + +- Whisper는 제한이 더 작고, Deepgram은 더 큰 파일을 지원합니다. +- 가능하다면 압축이나 청킹을 사용합니다. ### 오디오 파일이 보이지 않음 -- 파일이 vault 안에 있는지 확인합니다. -- 지원 형식(m4a, mp3, wav, mp4 등)을 확인합니다. -- Obsidian 인덱싱이 끝날 때까지 기다립니다. + +- 파일이 vault 안에 있는지 확인합니다. +- 지원 형식(m4a, mp3, wav, mp4 등)을 확인합니다. +- Obsidian 인덱싱이 끝날 때까지 기다립니다. ### 결과가 비어있거나 일부만 나옴 -- 다른 공급자로 시도하거나 재시도 옵션을 켭니다. -- 오디오 품질을 개선하고 동시에 말하는 구간을 줄입니다. + +- 다른 공급자로 시도하거나 재시도 옵션을 켭니다. +- 오디오 품질을 개선하고 동시에 말하는 구간을 줄입니다. ### 네트워크 오류 또는 타임아웃 -- 인터넷 연결을 확인합니다. -- 가능하면 타임아웃을 늘립니다. -- 공급자가 제한을 걸었을 수 있으니 잠시 후 재시도합니다. + +- 인터넷 연결을 확인합니다. +- 가능하면 타임아웃을 늘립니다. +- 공급자가 제한을 걸었을 수 있으니 잠시 후 재시도합니다. ### 화자 분리가 동작하지 않음 -- 설정에서 화자 분리가 켜져 있는지 확인합니다. -- 화자 분리를 지원하는 모델을 사용합니다. -- 화자별로 충분한 발화 길이가 있는지 확인합니다. + +- 설정에서 화자 분리가 켜져 있는지 확인합니다. +- 화자 분리를 지원하는 모델을 사용합니다. +- 화자별로 충분한 발화 길이가 있는지 확인합니다. ### 그래도 해결되지 않나요? -- 재현 방법과 로그를 정리합니다. -- GitHub Issue에 재현 단계와 기대/실제 결과를 작성합니다. + +- 재현 방법과 로그를 정리합니다. +- GitHub Issue에 재현 단계와 기대/실제 결과를 작성합니다. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 5a8a496..e83c088 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,87 +1,87 @@ -import esbuild from "esbuild"; -import process from "process"; -import builtins from "builtin-modules"; -import { promises as fs } from "fs"; -import path from "path"; +import esbuild from 'esbuild'; +import process from 'process'; +import builtins from 'builtin-modules'; +import { promises as fs } from 'fs'; +import path from 'path'; const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD Optimized for Phase 4 Performance */`; -const prod = (process.argv[2] === "production"); -const watch = !prod && process.argv[2] !== "build"; -const analyze = process.env.ANALYZE === "true"; +const prod = process.argv[2] === 'production'; +const watch = !prod && process.argv[2] !== 'build'; +const analyze = process.env.ANALYZE === 'true'; // Ensure output directory exists -const outdir = "."; +const outdir = '.'; await fs.mkdir(outdir, { recursive: true }); // Phase 4 최적화 설정 const optimizationConfig = { // Tree shaking 강화 treeShaking: true, - + // 사용하지 않는 코드 제거 - pure: prod ? ["console.log", "console.debug", "console.trace"] : [], - drop: prod ? ["debugger"] : [], - + pure: prod ? ['console.log', 'console.debug', 'console.trace'] : [], + drop: prod ? ['debugger'] : [], + // 번들 분석용 메타데이터 metafile: analyze, - + // 코드 스플리팅 설정 splitting: false, // Obsidian 플러그인은 단일 파일 필요 - + // 최적화 플래그 minify: prod, minifyWhitespace: prod, minifyIdentifiers: prod, minifySyntax: prod, - + // 소스맵 최적화 - sourcemap: prod ? false : "inline", - + sourcemap: prod ? false : 'inline', + // 타겟 최적화 - target: "es2018", - + target: 'es2018', + // 번들 크기 경고 - logLimit: 10 + logLimit: 10, }; const context = await esbuild.context({ banner: { js: banner, }, - entryPoints: ["src/main.ts"], + entryPoints: ['src/main.ts'], bundle: true, external: [ - "obsidian", - "electron", - "@codemirror/autocomplete", - "@codemirror/collab", - "@codemirror/commands", - "@codemirror/language", - "@codemirror/lint", - "@codemirror/search", - "@codemirror/state", - "@codemirror/view", - "@lezer/common", - "@lezer/highlight", - "@lezer/lr", - ...builtins + 'obsidian', + 'electron', + '@codemirror/autocomplete', + '@codemirror/collab', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lint', + '@codemirror/search', + '@codemirror/state', + '@codemirror/view', + '@lezer/common', + '@lezer/highlight', + '@lezer/lr', + ...builtins, ], - format: "cjs", - outfile: "main.js", - logLevel: "info", - + format: 'cjs', + outfile: 'main.js', + logLevel: 'info', + // Phase 4 최적화 적용 ...optimizationConfig, - + define: { 'process.env.NODE_ENV': prod ? '"production"' : '"development"', - 'process.env.PERFORMANCE_MONITORING': '"enabled"' + 'process.env.PERFORMANCE_MONITORING': '"enabled"', }, - + plugins: [ { name: 'clean', @@ -97,51 +97,56 @@ const context = await esbuild.context({ } } }); - } + }, }, { name: 'bundle-size-checker', setup(build) { build.onEnd(async (result) => { if (result.errors.length > 0) return; - + try { const stats = await fs.stat('main.js'); const sizeKB = (stats.size / 1024).toFixed(2); const sizeMB = (stats.size / 1024 / 1024).toFixed(2); - + console.log(`📦 Bundle size: ${sizeKB} KB (${sizeMB} MB)`); - + // Phase 4 목표: 초기 번들 < 150KB const targetSize = 150 * 1024; // 150KB in bytes - + if (stats.size > targetSize) { console.warn(`⚠️ Bundle size exceeds target of 150KB!`); console.warn(` Current: ${sizeKB} KB`); console.warn(` Target: 150.00 KB`); - console.warn(` Excess: ${((stats.size - targetSize) / 1024).toFixed(2)} KB`); + console.warn( + ` Excess: ${((stats.size - targetSize) / 1024).toFixed(2)} KB` + ); } else { console.log(`✅ Bundle size is within target (< 150KB)`); } - + // 메타데이터 분석 if (analyze && result.metafile) { - await fs.writeFile('meta.json', JSON.stringify(result.metafile, null, 2)); + await fs.writeFile( + 'meta.json', + JSON.stringify(result.metafile, null, 2) + ); console.log('📊 Bundle analysis saved to meta.json'); - + // 주요 모듈 크기 분석 const outputs = result.metafile.outputs; const inputs = result.metafile.inputs; - + console.log('\n📈 Top 10 largest modules:'); const modules = Object.entries(inputs) .map(([path, info]) => ({ path: path.replace('src/', ''), - size: info.bytes + size: info.bytes, })) .sort((a, b) => b.size - a.size) .slice(0, 10); - + modules.forEach((module, index) => { const sizeKB = (module.size / 1024).toFixed(2); console.log(` ${index + 1}. ${module.path}: ${sizeKB} KB`); @@ -151,26 +156,26 @@ const context = await esbuild.context({ console.error('Failed to check bundle size:', error); } }); - } + }, }, { name: 'performance-hints', setup(build) { - build.onEnd(result => { + build.onEnd((result) => { if (result.errors.length > 0) { console.error('❌ Build failed with errors:'); - result.errors.forEach(error => { + result.errors.forEach((error) => { console.error(error); }); } else if (result.warnings.length > 0) { console.warn('⚠️ Build succeeded with warnings:'); - result.warnings.forEach(warning => { + result.warnings.forEach((warning) => { console.warn(warning); }); } else { const time = new Date().toLocaleTimeString(); console.log(`✅ Build succeeded at ${time}`); - + if (prod) { console.log('\n💡 Performance optimization tips:'); console.log(' - Consider lazy loading for non-critical components'); @@ -180,37 +185,37 @@ const context = await esbuild.context({ } } }); - } - } - ] + }, + }, + ], }); if (prod) { // Production build with optimization report console.log('🚀 Starting optimized production build...'); await context.rebuild(); - + // Generate optimization report console.log('\n📋 Optimization Report:'); console.log(' ✅ Tree shaking: Enabled'); console.log(' ✅ Minification: Enabled'); console.log(' ✅ Dead code elimination: Enabled'); console.log(' ✅ Console statements: Removed'); - + console.log('\n🎯 Phase 4 Performance Targets:'); console.log(' - Initial bundle: < 150KB'); console.log(' - Total bundle: < 400KB'); console.log(' - TTI: < 2 seconds'); console.log(' - Memory usage: < 50MB'); - + process.exit(0); } else if (watch) { // Development with watch mode - console.log("👀 Watching for changes with performance monitoring..."); + console.log('👀 Watching for changes with performance monitoring...'); await context.watch(); } else { // Single development build await context.rebuild(); - console.log("✅ Development build complete"); + console.log('✅ Development build complete'); await context.dispose(); -} \ No newline at end of file +} diff --git a/jest.config.js b/jest.config.js index 43d5895..1f93b3d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,9 +8,9 @@ const tsJestTransform = [ tsconfig: { esModuleInterop: true, allowSyntheticDefaultImports: true, - skipLibCheck: true - } - } + skipLibCheck: true, + }, + }, ]; const moduleNameMapper = { @@ -24,17 +24,17 @@ const moduleNameMapper = { '^@types/(.*)$': '/src/types/$1', '\\.(css|less|scss|sass)$': '/tests/mocks/styleMock.js', '\\.(jpg|jpeg|png|gif|svg|webp)$': '/tests/mocks/fileMock.js', - '^obsidian$': '/tests/mocks/obsidian.mock.ts' + '^obsidian$': '/tests/mocks/obsidian.mock.ts', }; -const createProject = overrides => ({ +const createProject = (overrides) => ({ preset: 'ts-jest', transform: { - '^.+\\.(ts|tsx)$': tsJestTransform + '^.+\\.(ts|tsx)$': tsJestTransform, }, moduleNameMapper, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - ...overrides + ...overrides, }); module.exports = { @@ -50,11 +50,11 @@ module.exports = { 'jest-junit', { outputDirectory: './reports', - outputName: 'junit.xml' - } - ] + outputName: 'junit.xml', + }, + ], ] - : []) + : []), ], bail: isCI ? 1 : 0, errorOnDeprecated: true, @@ -71,7 +71,7 @@ module.exports = { '!src/types/**', '!src/main.ts', '!src/**/*.test.ts', - '!src/**/*.spec.ts' + '!src/**/*.spec.ts', ], coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov', 'html', 'json-summary'], @@ -87,9 +87,9 @@ module.exports = { branches: 50, functions: 25, lines: 10, - statements: 10 - } - } + statements: 10, + }, + }, }), createProject({ displayName: 'Integration Tests', @@ -102,9 +102,9 @@ module.exports = { branches: 50, functions: 25, lines: 3, - statements: 3 - } - } + statements: 3, + }, + }, }), createProject({ displayName: 'E2E Tests', @@ -118,9 +118,9 @@ module.exports = { branches: 50, functions: 25, lines: 3, - statements: 3 - } - } - }) - ] + statements: 3, + }, + }, + }), + ], }; diff --git a/manifest.json b/manifest.json index b783778..1fe19a5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ { - "id": "speech-to-text", - "name": "Speech to Text", - "version": "3.0.11", - "minAppVersion": "0.15.0", - "description": "Convert audio recordings to text using multiple AI providers (OpenAI Whisper, Deepgram)", - "author": "Taesun Lee", - "authorUrl": "https://github.com/asyouplz", - "fundingUrl": "", - "isDesktopOnly": false -} \ No newline at end of file + "id": "speech-to-text", + "name": "Speech to Text", + "version": "3.0.11", + "minAppVersion": "0.15.0", + "description": "Convert audio recordings to text using multiple AI providers (OpenAI Whisper, Deepgram)", + "author": "Taesun Lee", + "authorUrl": "https://github.com/asyouplz", + "fundingUrl": "", + "isDesktopOnly": false +} diff --git a/package.json b/package.json index 6b698a2..d36d905 100644 --- a/package.json +++ b/package.json @@ -1,89 +1,89 @@ { - "name": "obsidian-speech-to-text", - "version": "3.0.11", - "description": "Convert audio recordings to text in Obsidian using multiple AI providers (OpenAI Whisper, Deepgram)", - "main": "main.js", - "scripts": { - "dev": "node esbuild.config.mjs", - "build": "npx tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", - "lint": "eslint src/**/*.ts", - "lint:fix": "eslint src/**/*.ts --fix", - "lint:obsidian": "eslint src/**/*.ts --format=stylish", - "lint:verbose": "eslint src/**/*.ts --format=stylish --max-warnings=0", - "format": "prettier --write 'src/**/*.{ts,tsx}'", - "format:check": "prettier --check 'src/**/*.{ts,tsx}'", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "test:unit": "jest --selectProjects='Unit Tests'", - "test:integration": "jest --selectProjects='Integration Tests'", - "test:e2e": "jest --selectProjects='E2E Tests'", - "test:e2e:watch": "jest --selectProjects='E2E Tests' --watch", - "test:all": "npm run test:unit && npm run test:integration && npm run test:e2e", - "test:ci": "CI=true jest --ci --coverage --maxWorkers=2", - "test:debug": "DEBUG=true node --inspect-brk ./node_modules/.bin/jest --runInBand", - "test:changed": "jest -o", - "test:update-snapshots": "jest -u", - "coverage:report": "node scripts/open-coverage.js", - "coverage:clean": "rm -rf coverage .jest-cache", - "pretest": "npm run lint && npm run typecheck", - "posttest": "npm run coverage:report", - "version": "node version-bump.mjs && git add manifest.json versions.json", - "clean": "rm -rf main.js main.js.map coverage .jest-cache", - "clean:all": "npm run clean && rm -rf node_modules", - "typecheck": "npx tsc -noEmit", - "validate": "npm run lint && npm run typecheck && npm run test", - "ci": "npm run validate && npm run build", - "prepare": "husky" - }, - "keywords": [ - "obsidian", - "plugin", - "speech-to-text", - "whisper", - "transcription", - "audio", - "voice" - ], - "author": "Taesun Lee", - "license": "MIT", - "devDependencies": { - "@deepgram/sdk": "^3.9.0", - "@testing-library/jest-dom": "^6.9.1", - "@types/jest": "^29.5.12", - "@types/node": "^16.18.126", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "builtin-modules": "^3.3.0", - "esbuild": "0.25.0", - "eslint": "^8.42.0", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-obsidianmd": "^0.1.9", - "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-unused-imports": "^4.3.0", - "husky": "^9.1.7", - "jest": "^29.5.0", - "jest-environment-jsdom": "^29.5.0", - "jest-html-reporters": "^3.1.4", - "jest-junit": "^16.0.0", - "jest-progress-bar-reporter": "^1.0.25", - "jest-serializer-html": "^7.1.0", - "jest-sonar-reporter": "^2.0.0", - "jest-watch-typeahead": "^2.2.2", - "lint-staged": "^16.2.7", - "obsidian": "latest", - "prettier": "^2.8.8", - "ts-jest": "^29.1.0", - "tslib": "^2.4.0", - "typescript": "^4.9.5" - }, - "overrides": { - "npm": "11.7.0" - }, - "lint-staged": { - "*.ts": [ - "eslint --fix", - "prettier --write" - ] - } -} \ No newline at end of file + "name": "obsidian-speech-to-text", + "version": "3.0.11", + "description": "Convert audio recordings to text in Obsidian using multiple AI providers (OpenAI Whisper, Deepgram)", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "npx tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", + "lint:obsidian": "eslint src/**/*.ts --format=stylish", + "lint:verbose": "eslint src/**/*.ts --format=stylish --max-warnings=0", + "format": "prettier --write 'src/**/*.{ts,tsx}'", + "format:check": "prettier --check 'src/**/*.{ts,tsx}'", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:unit": "jest --selectProjects='Unit Tests'", + "test:integration": "jest --selectProjects='Integration Tests'", + "test:e2e": "jest --selectProjects='E2E Tests'", + "test:e2e:watch": "jest --selectProjects='E2E Tests' --watch", + "test:all": "npm run test:unit && npm run test:integration && npm run test:e2e", + "test:ci": "CI=true jest --ci --coverage --maxWorkers=2", + "test:debug": "DEBUG=true node --inspect-brk ./node_modules/.bin/jest --runInBand", + "test:changed": "jest -o", + "test:update-snapshots": "jest -u", + "coverage:report": "node scripts/open-coverage.js", + "coverage:clean": "rm -rf coverage .jest-cache", + "pretest": "npm run lint && npm run typecheck", + "posttest": "npm run coverage:report", + "version": "node version-bump.mjs && git add manifest.json versions.json", + "clean": "rm -rf main.js main.js.map coverage .jest-cache", + "clean:all": "npm run clean && rm -rf node_modules", + "typecheck": "npx tsc -noEmit", + "validate": "npm run lint && npm run typecheck && npm run test", + "ci": "npm run validate && npm run build", + "prepare": "husky" + }, + "keywords": [ + "obsidian", + "plugin", + "speech-to-text", + "whisper", + "transcription", + "audio", + "voice" + ], + "author": "Taesun Lee", + "license": "MIT", + "devDependencies": { + "@deepgram/sdk": "^3.9.0", + "@testing-library/jest-dom": "^6.9.1", + "@types/jest": "^29.5.12", + "@types/node": "^16.18.126", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "builtin-modules": "^3.3.0", + "esbuild": "0.25.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-obsidianmd": "^0.1.9", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-unused-imports": "^4.3.0", + "husky": "^9.1.7", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "jest-html-reporters": "^3.1.4", + "jest-junit": "^16.0.0", + "jest-progress-bar-reporter": "^1.0.25", + "jest-serializer-html": "^7.1.0", + "jest-sonar-reporter": "^2.0.0", + "jest-watch-typeahead": "^2.2.2", + "lint-staged": "^16.2.7", + "obsidian": "latest", + "prettier": "^2.8.8", + "ts-jest": "^29.1.0", + "tslib": "^2.4.0", + "typescript": "^4.9.5" + }, + "overrides": { + "npm": "11.7.0" + }, + "lint-staged": { + "*.ts": [ + "eslint --fix", + "prettier --write" + ] + } +} diff --git a/scripts/open-coverage.js b/scripts/open-coverage.js index 063eb5b..7204e98 100644 --- a/scripts/open-coverage.js +++ b/scripts/open-coverage.js @@ -4,8 +4,8 @@ const path = require('path'); const reportPath = path.join(__dirname, '..', 'coverage', 'lcov-report', 'index.html'); if (!fs.existsSync(reportPath)) { - console.warn('Coverage report not found. Skipping open step.'); - process.exit(0); + console.warn('Coverage report not found. Skipping open step.'); + process.exit(0); } console.log(`Coverage report: ${reportPath}`); diff --git a/src/core/transcription/TranscriptionService.ts b/src/core/transcription/TranscriptionService.ts index 50f38ba..fb4e4a2 100644 --- a/src/core/transcription/TranscriptionService.ts +++ b/src/core/transcription/TranscriptionService.ts @@ -1,4 +1,4 @@ -import { TFile, requestUrl, Notice } from 'obsidian'; +import { TFile, requestUrl } from 'obsidian'; import type { ITranscriptionService, TranscriptionResult, @@ -100,23 +100,23 @@ export class TranscriptionService implements ITranscriptionService { this.logger.debug('WhisperService response:', { hasResponse: !!response, - hasText: !!response?.text, - textLength: response?.text?.length || 0, - textPreview: response?.text?.substring(0, 100), - language: response?.language, + textLength: response?.text?.length, }); - // Validate response - if (!response || response.text === undefined || response.text === null) { - this.logger.error('Empty or invalid response from WhisperService', undefined, { - response, - }); - throw new Error('Transcription service returned empty text'); + if (!response) { + throw new Error('No response from WhisperService'); } + const text = response.text; + if (!text || typeof text !== 'string' || text.trim() === '') { + throw new Error('Transcription service returned empty or invalid text'); + } + + const _language = response.language || languagePreference; + // Format text this.status = 'formatting'; - const formattedText = this.textFormatter.format(response.text); + const formattedText = this.textFormatter.format(text); this.logger.debug('Text formatted:', { originalLength: response.text.length, @@ -126,9 +126,9 @@ export class TranscriptionService implements ITranscriptionService { const result: TranscriptionResult = { text: formattedText, - language: response.language, - segments: response.segments?.map((s, i) => ({ - id: i, + language: _language, + segments: (response.segments || []).map((s, i: number) => ({ + id: s.id ?? i, start: s.start, end: s.end, text: s.text, @@ -230,12 +230,15 @@ export class TranscriptionService implements ITranscriptionService { private createNoopAudioProcessor(): IAudioProcessor { return { + // eslint-disable-next-line @typescript-eslint/require-await validate: async () => { throw new Error('Audio processor not configured'); }, + // eslint-disable-next-line @typescript-eslint/require-await process: async () => { throw new Error('Audio processor not configured'); }, + // eslint-disable-next-line @typescript-eslint/require-await extractMetadata: async () => { throw new Error('Audio processor not configured'); }, @@ -244,10 +247,12 @@ export class TranscriptionService implements ITranscriptionService { private createNoopWhisperService(): IWhisperService { return { + // eslint-disable-next-line @typescript-eslint/require-await transcribe: async () => { throw new Error('Whisper service not configured'); }, cancel: () => undefined, + // eslint-disable-next-line @typescript-eslint/require-await validateApiKey: async () => false, }; } @@ -398,7 +403,7 @@ export class TranscriptionService implements ITranscriptionService { ok: response.status >= 200 && response.status < 300, status: response.status, statusText: String(response.status), - json: response.json, + json: response.json as unknown, text: response.text, })); @@ -426,7 +431,7 @@ export class TranscriptionService implements ITranscriptionService { signal.addEventListener('abort', abortHandler, { once: true }); } if (signal && 'onabort' in signal) { - const sig = signal as AbortSignal; + const sig = signal; const previous = sig.onabort; sig.onabort = (event: Event) => { if (typeof previous === 'function') { @@ -455,7 +460,7 @@ export class TranscriptionService implements ITranscriptionService { 'removeEventListener' in signal && typeof signal.removeEventListener === 'function' ) { - (signal as AbortSignal).removeEventListener('abort', abortHandler); + signal.removeEventListener('abort', abortHandler); } if (restoreOnAbort) { restoreOnAbort(); @@ -637,8 +642,9 @@ export class TranscriptionService implements ITranscriptionService { } if ( this.isTestEnvironment() && + typeof setTimeout === 'function' && 'mock' in setTimeout && - typeof (setTimeout as any).mock === 'object' && + isPlainRecord((setTimeout as unknown as { mock: unknown }).mock) && !signal ) { return promise; @@ -701,7 +707,7 @@ export class TranscriptionService implements ITranscriptionService { private normalizeError(error: unknown): Error { if (error instanceof Error) { - const errorCode = Reflect.get(error, 'code'); + const errorCode = Reflect.get(error, 'code') as unknown; if (errorCode === 'MAX_RETRIES_EXCEEDED' && error.message.includes(':')) { const parts = error.message.split(':'); const originalMessage = parts[parts.length - 1]?.trim(); diff --git a/src/domain/models/Settings.ts b/src/domain/models/Settings.ts index b6b04d6..18fc592 100644 --- a/src/domain/models/Settings.ts +++ b/src/domain/models/Settings.ts @@ -4,7 +4,8 @@ import type { DeepgramFeatures } from '../../types/DeepgramTypes'; export interface SpeechToTextSettings { apiKey: string; apiEndpoint?: string; // Added for custom API endpoint support - model: WhisperModel | string; // Allow string for compatibility + // eslint-disable-next-line @typescript-eslint/ban-types + model: WhisperModel | (string & {}); // Allow string for compatibility language: LanguageCode; autoInsert: boolean; insertPosition: InsertPosition; @@ -123,7 +124,8 @@ export interface SpeechToTextSettings { } export type WhisperModel = 'whisper-1'; -export type LanguageCode = 'auto' | 'en' | 'ko' | 'ja' | 'zh' | 'es' | 'fr' | 'de' | string; +// eslint-disable-next-line @typescript-eslint/ban-types +export type LanguageCode = 'auto' | 'en' | 'ko' | 'ja' | 'zh' | 'es' | 'fr' | 'de' | (string & {}); export type InsertPosition = 'cursor' | 'end' | 'beginning'; export type TimestampFormat = 'none' | 'inline' | 'sidebar'; diff --git a/src/infrastructure/api/BatchRequestManager.ts b/src/infrastructure/api/BatchRequestManager.ts index c81e2a1..b07fc79 100644 --- a/src/infrastructure/api/BatchRequestManager.ts +++ b/src/infrastructure/api/BatchRequestManager.ts @@ -1,4 +1,5 @@ import { requestUrl } from 'obsidian'; +import { isPlainRecord } from '../../types/guards'; /** * BatchRequestManager - Phase 4 Performance Optimization @@ -120,7 +121,10 @@ export class BatchRequestManager { this.queues.set(batchKey, []); } - const queue = this.queues.get(batchKey)!; + const queue = this.queues.get(batchKey) ?? []; + if (!this.queues.has(batchKey)) { + this.queues.set(batchKey, queue); + } queue.push(request); // 우선순위 정렬 @@ -229,7 +233,8 @@ export class BatchRequestManager { throw new Error(`Batch request failed with status ${response.status}`); } - const data = response.json ?? (response.text ? JSON.parse(response.text) : {}); + const json = response.json as unknown; + const data = (isPlainRecord(json) ? json : {}) as { responses?: BatchResponse[] }; return data.responses || []; } @@ -260,9 +265,10 @@ export class BatchRequestManager { */ private handleBatchError(batch: AnyBatch[], error: Error): void { batch.forEach((request) => { - if (request.retries! < this.maxRetries) { + const currentRetries = request.retries ?? 0; + if (currentRetries < this.maxRetries) { // 재시도 - request.retries!++; + request.retries = currentRetries + 1; this.enqueueRequest(request); } else { // 최종 실패 diff --git a/src/infrastructure/api/FileUploadManager.ts b/src/infrastructure/api/FileUploadManager.ts index a49daae..cd6fab5 100644 --- a/src/infrastructure/api/FileUploadManager.ts +++ b/src/infrastructure/api/FileUploadManager.ts @@ -87,7 +87,7 @@ export class FileUploadManager { message: 'Validating file...', }); - await this.validateFile(file); + this.validateFile(file); this.ensureNotCancelled(); // 2. 파일 읽기 diff --git a/src/infrastructure/api/SettingsAPI.ts b/src/infrastructure/api/SettingsAPI.ts index edea389..4d8cda8 100644 --- a/src/infrastructure/api/SettingsAPI.ts +++ b/src/infrastructure/api/SettingsAPI.ts @@ -13,6 +13,7 @@ import type { ResetScope, } from '../../types/phase3-api'; import type { Unsubscribe } from '../../types/events'; +import { isPlainRecord } from '../../types/guards'; import { SecureApiKeyManager, SettingsEncryptor } from '../security/Encryptor'; import { SettingsMigrator } from './SettingsMigrator'; import { SettingsValidator } from './SettingsValidator'; @@ -47,19 +48,20 @@ export class SettingsAPI implements ISettingsAPI { async initialize(): Promise { try { // Obsidian API를 통해 설정 로드 - const stored = this.app.loadLocalStorage(this.storageKey); + const stored = this.app.loadLocalStorage(this.storageKey) as string | null; if (stored) { - const parsed = JSON.parse(stored); + const parsed = JSON.parse(stored) as unknown; // 마이그레이션 확인 if (this.needsMigration()) { + const parsedObj = isPlainRecord(parsed) ? parsed : {}; this.settings = await this.migrator.migrate( - parsed, - parsed.version || '1.0.0', + parsedObj as unknown as SettingsSchema, + (parsedObj.version as string) || '1.0.0', this.defaultSettings.version ); } else { - this.settings = parsed; + this.settings = parsed as SettingsSchema; } } @@ -178,12 +180,13 @@ export class SettingsAPI implements ISettingsAPI { * 마이그레이션 필요 여부 */ needsMigration(): boolean { - const stored = this.app.loadLocalStorage(this.storageKey); - if (!stored) return false; + const stored = this.app.loadLocalStorage(this.storageKey) as unknown; + if (typeof stored !== 'string') return false; try { - const parsed = JSON.parse(stored); - return parsed.version !== this.defaultSettings.version; + const parsed = JSON.parse(stored) as unknown; + const parsedObj = isPlainRecord(parsed) ? parsed : {}; + return parsedObj.version !== (this.defaultSettings.version as unknown as string); } catch { return false; } @@ -223,10 +226,10 @@ export class SettingsAPI implements ISettingsAPI { const encoder = new TextEncoder(); const data = encoder.encode(json); const compressed = await this.compress(data); - return new Blob([compressed], { type: 'application/gzip' }); + return new Blob([compressed as any], { type: 'application/gzip' }); } - return new Blob([json], { type: 'application/json' }); + return new Blob([json as any], { type: 'application/json' }); } /** @@ -245,11 +248,17 @@ export class SettingsAPI implements ISettingsAPI { data = await file.text(); } - let importedSettings = JSON.parse(data); + const parsed = JSON.parse(data) as unknown; + if (!isPlainRecord(parsed)) { + throw new Error('Invalid settings file: expected an object'); + } + let importedSettings = parsed as unknown as Partial; // 암호화된 파일 처리 if (options.password) { - importedSettings = await this.encryptor.decryptSensitiveSettings(importedSettings); + importedSettings = (await this.encryptor.decryptSensitiveSettings( + importedSettings as Record + )) as unknown as Partial; } // 검증 @@ -269,10 +278,10 @@ export class SettingsAPI implements ISettingsAPI { if (options.merge) { Object.assign(this.settings, importedSettings); } else if (options.overwrite) { - this.settings = importedSettings; + this.settings = importedSettings as SettingsSchema; } else { // 기본: 안전한 병합 (API 키 제외) - const { api, ...safeSettings } = importedSettings; + const { api: _api, ...safeSettings } = importedSettings; Object.assign(this.settings, safeSettings); } @@ -280,7 +289,7 @@ export class SettingsAPI implements ISettingsAPI { return { success: true, - imported: importedSettings, + imported: importedSettings as unknown as SettingsSchema, warnings: validation?.warnings?.map((w) => w.message), }; } catch (error) { @@ -298,7 +307,7 @@ export class SettingsAPI implements ISettingsAPI { async reset(scope: ResetScope = 'all'): Promise { if (scope === 'all') { this.settings = { ...this.defaultSettings }; - await this.apiKeyManager.clearApiKey(); + this.apiKeyManager.clearApiKey(); } else if (Array.isArray(scope)) { scope.forEach((key) => { const typedKey = key; @@ -320,7 +329,10 @@ export class SettingsAPI implements ISettingsAPI { if (!this.listeners.has(event)) { this.listeners.set(event, []); } - const listeners = this.listeners.get(event)!; + const listeners = this.listeners.get(event) ?? []; + if (!this.listeners.has(event)) { + this.listeners.set(event, listeners); + } // Prevent duplicate listeners if (!listeners.includes(listener)) { listeners.push(listener); @@ -450,7 +462,7 @@ export class SettingsAPI implements ISettingsAPI { if ('CompressionStream' in window) { const cs = new ( window as typeof window & { CompressionStream: new (type: string) => unknown } - ).CompressionStream('gzip') as unknown as { + ).CompressionStream('gzip') as { writable: WritableStream; readable: ReadableStream; }; @@ -495,7 +507,7 @@ export class SettingsAPI implements ISettingsAPI { if ('DecompressionStream' in window) { const ds = new ( window as typeof window & { DecompressionStream: new (type: string) => unknown } - ).DecompressionStream('gzip') as unknown as { + ).DecompressionStream('gzip') as { writable: WritableStream; readable: ReadableStream; }; diff --git a/src/infrastructure/security/Encryptor.ts b/src/infrastructure/security/Encryptor.ts index 5179795..4b391af 100644 --- a/src/infrastructure/security/Encryptor.ts +++ b/src/infrastructure/security/Encryptor.ts @@ -3,7 +3,7 @@ * Web Crypto API를 사용한 안전한 데이터 암호화/복호화 */ -import { type App, Notice, Platform } from 'obsidian'; +import { type App, Notice } from 'obsidian'; export interface EncryptedData { data: string; // Base64 encoded encrypted data diff --git a/src/infrastructure/storage/SettingsManager.ts b/src/infrastructure/storage/SettingsManager.ts index b930db7..4ee0bc3 100644 --- a/src/infrastructure/storage/SettingsManager.ts +++ b/src/infrastructure/storage/SettingsManager.ts @@ -172,7 +172,7 @@ export class SettingsManager implements ISettingsManager { if (settings.apiKey) { settings.encryptedApiKey = this.encryption.encrypt(settings.apiKey); // 원본 API 키는 저장하지 않음 - const { apiKey: _apiKey, ...saveData } = settings; + const { apiKey: _, ...saveData } = settings; await this.plugin.saveData(saveData); } else { await this.plugin.saveData(settings); @@ -232,7 +232,7 @@ export class SettingsManager implements ISettingsManager { this.settings.encryptedApiKey = this.encryption.encrypt(apiKey); // 저장 시 API 키는 제외 - const { apiKey: _apiKey, ...saveData } = this.settings; + const { apiKey: _, ...saveData } = this.settings; await this.plugin.saveData(saveData); this.logger?.debug('API key encrypted and saved', { @@ -249,14 +249,14 @@ export class SettingsManager implements ISettingsManager { // 설정 내보내기 (민감한 정보 제외) exportSettings(): Partial { - const { apiKey: _apiKey, encryptedApiKey: _encryptedApiKey, ...exported } = this.settings; + const { apiKey: _, encryptedApiKey: __, ...exported } = this.settings; return exported; } // 설정 가져오기 async importSettings(imported: Partial): Promise { // API 키는 가져오지 않음 - const { apiKey: _apiKey, encryptedApiKey: _encryptedApiKey, ...safeImported } = imported; + const { apiKey: _, encryptedApiKey: __, ...safeImported } = imported; this.settings = { ...this.settings, ...safeImported }; await this.save(this.settings); diff --git a/src/main.ts b/src/main.ts index 015b24e..7b8dbf4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -334,6 +334,7 @@ export default class SpeechToTextPlugin extends Plugin { id: 'transcribe-audio-file', name: 'Transcribe audio file', callback: () => { + // Fire-and-forget audio file picker void this.showAudioFilePicker(); }, }); @@ -570,8 +571,8 @@ export default class SpeechToTextPlugin extends Plugin { } // Show file picker modal - new AudioFilePickerModal(this.app, audioFiles, async (file) => { - await this.transcribeFile(file); + new AudioFilePickerModal(this.app, audioFiles, (file) => { + void this.transcribeFile(file); }).open(); } @@ -728,10 +729,10 @@ export default class SpeechToTextPlugin extends Plugin { addTimestamp: this.settings.addTimestamp || false, language: this.settings.language, }, - async (options) => { + (options) => { // Apply formatting and insert if (text) { - await this.textInsertionHandler.insertText(text, options); + void this.textInsertionHandler.insertText(text, options); } else { new Notice('No text to insert'); } @@ -745,7 +746,8 @@ export default class SpeechToTextPlugin extends Plugin { } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + const loadedData = (await this.loadData()) as Record | null; + this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData ?? {}); } async saveSettings() { diff --git a/src/ui/settings/EnhancedSettingsTab.ts b/src/ui/settings/EnhancedSettingsTab.ts index ca3c408..a28b693 100644 --- a/src/ui/settings/EnhancedSettingsTab.ts +++ b/src/ui/settings/EnhancedSettingsTab.ts @@ -19,7 +19,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { private validator: SettingsValidator; private memoryManager: ResourceManager; private isDirty = false; - private autoSaveTimeout: NodeJS.Timeout | null = null; + private autoSaveTimeout: number | null = null; constructor(app: App, plugin: SpeechToTextPlugin) { super(app, plugin); @@ -29,7 +29,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { this.validator = new SettingsValidator(); this.memoryManager = new ResourceManager(); - // 초기화 + // 초기화 (Fire-and-forget) void this.initialize(); } @@ -88,7 +88,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { private async initialize(): Promise { await this.settingsAPI.initialize(); - // 변경 감지 리스너 + // 변경 감지 리스너 (Fire-and-forget auto-save) const unsubscribe = this.settingsAPI.on('change', () => { this.isDirty = true; this.scheduleAutoSave(); @@ -131,10 +131,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { // 제목 const titleContainer = headerEl.createDiv({ cls: 'header-title-container' }); - titleContainer.createEl('h2', { - text: 'Speech to text settings', - cls: 'settings-title', - }); + new Setting(titleContainer).setName('Speech Note').setHeading(); // 상태 표시 const statusBadge = titleContainer.createEl('span', { @@ -160,8 +157,8 @@ export class EnhancedSettingsTab extends PluginSettingTab { new ButtonComponent(quickActions) .setButtonText('Save all') .setCta() - .onClick(async () => { - await this.saveSettings(); + .onClick(() => { + this.saveSettings(); new Notice('Settings saved successfully'); }); @@ -198,7 +195,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { tabEl.setAttribute('data-tab', tab.id); - tabEl.addEventListener('click', async () => { + tabEl.addEventListener('click', () => { // 활성 탭 업데이트 tabContainer.querySelectorAll('.settings-tab').forEach((el) => { el.removeClass('active'); @@ -208,7 +205,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { // 콘텐츠 표시 const contentContainer = containerEl.querySelector('.settings-content'); if (contentContainer instanceof HTMLElement) { - await this.showTabContent(contentContainer, tab.id); + void this.showTabContent(contentContainer, tab.id); } }); }); @@ -253,7 +250,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { private showGeneralSettings(container: HTMLElement): void { const section = container.createDiv({ cls: 'settings-section' }); - new Setting(section).setName('General settings').setHeading(); + new Setting(section).setName('Transcription').setHeading(); // 언어 설정 new Setting(section) @@ -387,7 +384,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { private async showApiSettings(container: HTMLElement): Promise { const section = container.createDiv({ cls: 'settings-section' }); - new Setting(section).setName('API configuration').setHeading(); + new Setting(section).setName('API').setHeading(); // API 프로바이더 new Setting(section) @@ -445,13 +442,14 @@ export class EnhancedSettingsTab extends PluginSettingTab { toggleBtn.setText('👁️'); let isVisible = false; - toggleBtn.addEventListener('click', async () => { + toggleBtn.addEventListener('click', () => { isVisible = !isVisible; if (isVisible) { inputEl.type = 'text'; if (hasKey) { - const key = await this.apiKeyManager.getApiKey(); - if (key) inputEl.value = key; + void this.apiKeyManager.getApiKey().then((key) => { + if (key) inputEl.value = key; + }); } toggleBtn.setText('🙈'); } else { @@ -470,7 +468,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { cls: 'mod-cta api-key-validate', }); - validateBtn.addEventListener('click', async () => { + validateBtn.addEventListener('click', () => { const value = inputEl.value; if (!value) { new Notice('Please enter an API key'); @@ -480,28 +478,30 @@ export class EnhancedSettingsTab extends PluginSettingTab { validateBtn.disabled = true; validateBtn.textContent = 'Validating...'; - try { - // API 키 검증 - const validation = SettingsValidator.validateApiKey(value); + void (async () => { + try { + // API 키 검증 + const validation = SettingsValidator.validateApiKey(value); - if (validation.valid) { - // 암호화 저장 - await this.apiKeyManager.storeApiKey(value); - new Notice('✅ API key validated and saved securely'); - inputEl.value = ''; - inputEl.placeholder = '••••••••••••••••'; - inputEl.addClass('has-value'); - } else { - const error = validation.errors?.[0]?.message || 'Invalid API key'; - new Notice(`❌ ${error}`); + if (validation.valid) { + // 암호화 저장 + await this.apiKeyManager.storeApiKey(value); + new Notice('✅ API key validated and saved securely'); + inputEl.value = ''; + inputEl.placeholder = '••••••••••••••••'; + inputEl.addClass('has-value'); + } else { + const error = validation.errors?.[0]?.message || 'Invalid API key'; + new Notice(`❌ ${error}`); + } + } catch (error) { + new Notice('❌ Failed to validate API key'); + console.error(error); + } finally { + validateBtn.disabled = false; + validateBtn.textContent = 'Validate'; } - } catch (error) { - new Notice('❌ Failed to validate API key'); - console.error(error); - } finally { - validateBtn.disabled = false; - validateBtn.textContent = 'Validate'; - } + })(); }); // 모델 선택 @@ -573,7 +573,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { private async showAudioSettings(container: HTMLElement): Promise { const section = container.createDiv({ cls: 'settings-section' }); - new Setting(section).setName('Audio settings').setHeading(); + new Setting(section).setName('Audio').setHeading(); const audio = await this.settingsAPI.get('audio'); @@ -686,13 +686,13 @@ export class EnhancedSettingsTab extends PluginSettingTab { private async showAdvancedSettings(container: HTMLElement): Promise { const section = container.createDiv({ cls: 'settings-section' }); - new Setting(section).setName('Advanced settings').setHeading(); + new Setting(section).setName('Advanced').setHeading(); const advanced = await this.settingsAPI.get('advanced'); // 캐시 설정 const cacheSection = section.createDiv({ cls: 'sub-section' }); - new Setting(cacheSection).setName('Cache settings').setHeading(); + new Setting(cacheSection).setName('Cache').setHeading(); new Setting(cacheSection) .setName('Enable cache') @@ -737,7 +737,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { // 성능 설정 const perfSection = section.createDiv({ cls: 'sub-section' }); - new Setting(perfSection).setName('Performance settings').setHeading(); + new Setting(perfSection).setName('Performance').setHeading(); new Setting(perfSection) .setName('Max concurrency') @@ -797,7 +797,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { // 디버그 설정 const debugSection = section.createDiv({ cls: 'sub-section' }); - new Setting(debugSection).setName('Debug settings').setHeading(); + new Setting(debugSection).setName('Debug').setHeading(); new Setting(debugSection) .setName('Enable debug mode') @@ -869,7 +869,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { text.setValue(shortcuts[item.key]); // 단축키 캡처 - text.inputEl.addEventListener('keydown', async (e) => { + text.inputEl.addEventListener('keydown', (e) => { e.preventDefault(); const modifiers = []; @@ -883,7 +883,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { text.setValue(shortcut); shortcuts[item.key] = shortcut; - await this.settingsAPI.set('shortcuts', shortcuts); + void this.settingsAPI.set('shortcuts', shortcuts); } }); }); @@ -985,8 +985,8 @@ export class EnhancedSettingsTab extends PluginSettingTab { await this.exportSettings(); }); - new ButtonComponent(portSection).setButtonText('📥 import settings').onClick(async () => { - await this.importSettings(); + new ButtonComponent(portSection).setButtonText('📥 import settings').onClick(() => { + this.importSettings(); }); // 도움말 링크 @@ -1030,11 +1030,11 @@ export class EnhancedSettingsTab extends PluginSettingTab { */ private scheduleAutoSave(): void { if (this.autoSaveTimeout) { - clearTimeout(this.autoSaveTimeout); + window.clearTimeout(this.autoSaveTimeout); } - this.autoSaveTimeout = setTimeout(async () => { - await this.saveSettings(); + this.autoSaveTimeout = window.setTimeout(() => { + this.saveSettings(); this.isDirty = false; new Notice('Settings auto-saved', 2000); }, 5000); // 5초 후 자동 저장 @@ -1145,7 +1145,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { */ onClose(): void { if (this.autoSaveTimeout) { - clearTimeout(this.autoSaveTimeout); + window.clearTimeout(this.autoSaveTimeout); } this.memoryManager.dispose(); } diff --git a/src/ui/settings/SettingsTab.ts b/src/ui/settings/SettingsTab.ts index 0fe8817..5e18b25 100644 --- a/src/ui/settings/SettingsTab.ts +++ b/src/ui/settings/SettingsTab.ts @@ -48,7 +48,7 @@ export class SettingsTab extends PluginSettingTab { containerEl.empty(); // Add main title - new Setting(containerEl).setName('Speech to text settings').setHeading(); + new Setting(containerEl).setName('Speech Note').setHeading(); this.debug('Title setting created'); // Add debug info section at the top @@ -119,7 +119,7 @@ export class SettingsTab extends PluginSettingTab { } private createApiSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('API configuration').setHeading(); + new Setting(containerEl).setName('API').setHeading(); // Provider 선택 섹션 const providerContainer = containerEl.createEl('div', { cls: 'provider-selection' }); @@ -308,7 +308,7 @@ export class SettingsTab extends PluginSettingTab { } private renderWhisperSettings(containerEl: HTMLElement): void { - new Setting(containerEl).setName('OpenAI Whisper configuration').setHeading(); + new Setting(containerEl).setName('Whisper').setHeading(); // Whisper API Key this.renderWhisperApiKey(containerEl); @@ -448,7 +448,7 @@ export class SettingsTab extends PluginSettingTab { } private createGeneralSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('General settings').setHeading(); + new Setting(containerEl).setName('Transcription').setHeading(); // Language setting new Setting(containerEl) @@ -517,7 +517,7 @@ export class SettingsTab extends PluginSettingTab { } private createAudioSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Audio settings').setHeading(); + new Setting(containerEl).setName('Audio').setHeading(); // Model selection - Provider에 따라 다르게 표시 const provider = this.plugin.settings.provider || 'auto'; @@ -570,7 +570,7 @@ export class SettingsTab extends PluginSettingTab { } private createAdvancedSection(containerEl: HTMLElement): void { - new Setting(containerEl).setName('Advanced settings').setHeading(); + new Setting(containerEl).setName('Advanced').setHeading(); // Enable cache new Setting(containerEl) @@ -608,6 +608,7 @@ export class SettingsTab extends PluginSettingTab { button .setButtonText('Reset') .setWarning() + // eslint-disable-next-line @typescript-eslint/require-await .onClick(async () => { new ConfirmationModal( this.app, diff --git a/src/ui/settings/provider/ProviderSettingsContainer.ts b/src/ui/settings/provider/ProviderSettingsContainer.ts index 095fd1a..1ad8c0c 100644 --- a/src/ui/settings/provider/ProviderSettingsContainer.ts +++ b/src/ui/settings/provider/ProviderSettingsContainer.ts @@ -43,12 +43,12 @@ export class ProviderSettingsContainer { /** * 초기화 */ - private async initialize(): Promise { + private initialize(): void { // 현재 설정 로드 - await this.loadSettings(); + this.loadSettings(); // 연결 상태 확인 - await this.checkAllConnections(); + this.checkAllConnections(); // 실시간 상태 업데이트 시작 this.startStatusMonitoring(); @@ -95,10 +95,7 @@ export class ProviderSettingsContainer { // 타이틀 const titleEl = headerEl.createDiv({ cls: 'provider-title' }); - titleEl.createEl('h3', { - text: '🎯 Transcription provider configuration', - cls: 'provider-title-text', - }); + new Setting(titleEl).setName('🎯 Transcription provider Configuration').setHeading(); // 확장/축소 토글 const toggleBtn = headerEl.createEl('button', { @@ -236,10 +233,10 @@ export class ProviderSettingsContainer { .setDesc('How should the system choose between providers?') .addDropdown((dropdown) => { dropdown - .addOption(SelectionStrategy.PERFORMANCE_OPTIMIZED, '⚡ Performance First') - .addOption(SelectionStrategy.COST_OPTIMIZED, '💰 Cost Optimized') - .addOption(SelectionStrategy.QUALITY_OPTIMIZED, '✨ Quality First') - .addOption(SelectionStrategy.ROUND_ROBIN, '🔄 Round Robin') + .addOption(SelectionStrategy.PERFORMANCE_OPTIMIZED, '⚡ Performance first') + .addOption(SelectionStrategy.COST_OPTIMIZED, '💰 Cost optimized') + .addOption(SelectionStrategy.QUALITY_OPTIMIZED, '✨ Quality first') + .addOption(SelectionStrategy.ROUND_ROBIN, '🔄 Round robin') .setValue( this.plugin.settings.selectionStrategy || SelectionStrategy.PERFORMANCE_OPTIMIZED @@ -271,8 +268,8 @@ export class ProviderSettingsContainer { await this.verifyAllApiKeys(); }); - new ButtonComponent(actionsEl).setButtonText('Import keys').onClick(async () => { - await this.importApiKeys(); + new ButtonComponent(actionsEl).setButtonText('Import keys').onClick(() => { + this.importApiKeys(); }); } @@ -311,8 +308,8 @@ export class ProviderSettingsContainer { }); // 설정 내보내기 - new ButtonComponent(actionsEl).setButtonText('Export config').onClick(async () => { - await this.exportConfiguration(); + new ButtonComponent(actionsEl).setButtonText('Export config').onClick(() => { + this.exportConfiguration(); }); // 설정 초기화 @@ -414,14 +411,14 @@ export class ProviderSettingsContainer { */ private showProviderHelp(): void { const modal = new Modal(this.app); - modal.titleEl.setText('Provider Selection Guide'); + modal.titleEl.setText('Provider selection guide'); const contentEl = modal.contentEl; const helpContainer = contentEl.createDiv('provider-help'); const sections = [ { - title: '🤖 Automatic Mode', + title: '🤖 Automatic mode', description: 'The system intelligently selects the best provider based on:', bullets: [ 'Current availability and response times', @@ -448,7 +445,7 @@ export class ProviderSettingsContainer { ]; sections.forEach((section) => { - helpContainer.createEl('h4', { text: section.title }); + new Setting(helpContainer).setName(section.title).setHeading(); if (section.description) { helpContainer.createEl('p', { text: section.description }); } @@ -467,7 +464,7 @@ export class ProviderSettingsContainer { /** * 모든 연결 확인 */ - private async checkAllConnections(): Promise { + private checkAllConnections(): void { const providers: TranscriptionProvider[] = ['whisper', 'deepgram']; for (const provider of providers) { @@ -497,8 +494,8 @@ export class ProviderSettingsContainer { */ private startStatusMonitoring(): void { // 5분마다 상태 업데이트 - this.statusUpdateInterval = window.setInterval(async () => { - await this.checkAllConnections(); + this.statusUpdateInterval = window.setInterval(() => { + this.checkAllConnections(); }, 5 * 60 * 1000); } @@ -631,7 +628,7 @@ export class ProviderSettingsContainer { private confirmReset(): Promise { return new Promise((resolve) => { const modal = new Modal(this.app); - modal.titleEl.setText('Reset Provider Settings?'); + modal.titleEl.setText('Reset provider settings?'); modal.contentEl.createEl('p', { text: 'This will reset all provider settings to defaults. API keys will be preserved.', @@ -717,12 +714,12 @@ export class ProviderSettingsContainer { private isSelectionStrategy(value: string): value is SelectionStrategy { return ( - value === SelectionStrategy.MANUAL || - value === SelectionStrategy.COST_OPTIMIZED || - value === SelectionStrategy.PERFORMANCE_OPTIMIZED || - value === SelectionStrategy.QUALITY_OPTIMIZED || - value === SelectionStrategy.ROUND_ROBIN || - value === SelectionStrategy.AB_TEST + value === (SelectionStrategy.MANUAL as string) || + value === (SelectionStrategy.COST_OPTIMIZED as string) || + value === (SelectionStrategy.PERFORMANCE_OPTIMIZED as string) || + value === (SelectionStrategy.QUALITY_OPTIMIZED as string) || + value === (SelectionStrategy.ROUND_ROBIN as string) || + value === (SelectionStrategy.AB_TEST as string) ); } @@ -751,7 +748,7 @@ class ProviderDetailsModal extends Modal { onOpen(): void { const { contentEl, titleEl } = this; - titleEl.setText(`${this.getProviderName()} Details`); + titleEl.setText(`${this.getProviderName()} details`); // 상태 정보 const statusEl = contentEl.createDiv({ cls: 'provider-details-status' }); @@ -859,7 +856,7 @@ class ProviderMetricsDisplay { constructor(private plugin: SpeechToTextPlugin) {} render(containerEl: HTMLElement): void { - containerEl.createEl('h4', { text: '📊 Performance metrics' }); + new Setting(containerEl).setName('📊 Performance metrics').setHeading(); // TODO: 실제 메트릭 구현 const metricsEl = containerEl.createDiv({ cls: 'metrics-display' }); diff --git a/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts b/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts index 65bdb28..f73ab28 100644 --- a/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts +++ b/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts @@ -21,6 +21,13 @@ interface ProviderSettingsState { error: string | null; } +interface ProviderMetrics { + totalRequests: number; + successRate: number; + avgLatency: number; + totalCost: number; +} + /** * Provider Settings Container (리팩토링 버전) * @@ -37,7 +44,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { private state: SettingsState; // 메모이제이션을 위한 캐시 - private memoCache = new Map(); + private memoCache = new Map(); // 실시간 업데이트 간격 private statusUpdateInterval?: number; @@ -62,26 +69,28 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { this.apiKeyManager = new APIKeyManager(plugin); this.advancedPanel = new AdvancedSettingsPanel(plugin); + // Fire-and-forget initialization void this.initialize(); } /** * 초기화 (비동기 작업 처리) */ - private async initialize(): Promise { - await this.withErrorHandling(async () => { + private initialize(): void { + void this.withErrorHandling(async () => { this.state.set((prev) => ({ ...prev, isLoading: true })); // 설정 로드 - await this.loadSettings(); + this.loadSettings(); - // 연결 상태 확인 (병렬 처리) - await this.checkAllConnectionsOptimized(); + // 연결 상태 확인 (병렬 처리, fire-and-forget) + void this.checkAllConnectionsOptimized(); // 실시간 모니터링 시작 this.startStatusMonitoring(); this.state.set((prev) => ({ ...prev, isLoading: false })); + return Promise.resolve(); }, 'Provider 초기화 실패'); } @@ -204,7 +213,11 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { * 상태 대시보드 렌더링 (최적화) */ private renderStatusDashboard(containerEl: HTMLElement): void { - const dashboardEl = this.createSection(containerEl, '상태 대시보드', '시스템 전체 상태'); + const dashboardEl = this.createSection( + containerEl, + 'Status dashboard', + 'System overall status' + ); // 메모이제이션된 상태 가져오기 const overallStatus = this.memoized('overallStatus', () => this.calculateOverallStatus()); @@ -232,7 +245,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { providers.forEach((provider) => { const hasKey = this.hasApiKey(provider); const isConnected = state.connectionStatus.get(provider) || false; - state.lastValidation.get(provider); + // const lastValidation = state.lastValidation.get(provider); // Unused variable UIComponentFactory.createCard( containerEl, @@ -240,13 +253,19 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { this.getProviderStatusText(provider, hasKey, isConnected), [ { - text: '상세 정보', - onClick: () => this.showProviderDetails(provider), + text: 'Details', + onClick: () => { + // Fire-and-forget 상세 표시 + void this.showProviderDetails(provider); + }, type: 'secondary', }, { - text: '테스트', - onClick: () => this.testProvider(provider), + text: 'Test', + onClick: () => { + // Fire-and-forget 테스트 실행 + void this.testProvider(provider); + }, type: 'primary', }, ] @@ -258,33 +277,39 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { * 빠른 설정 렌더링 */ private renderQuickSettings(containerEl: HTMLElement): void { - const section = this.createSection(containerEl, '빠른 설정'); + const section = this.createSection(containerEl, 'Quick settings'); // Provider 모드 - this.createSetting(section, 'Provider 모드', '자동 또는 수동 선택').addDropdown( - (dropdown) => { - dropdown - .addOption('auto', '🤖 자동') - .addOption('whisper', '🎯 Whisper') - .addOption('deepgram', '🚀 Deepgram') - .setValue(this.state.get().currentProvider) - .onChange((value) => this.handleProviderChange(value)); - } - ); + this.createSetting( + section, + 'Provider mode', + 'Select how to choose the transcription provider' + ).addDropdown((dropdown) => { + dropdown + .addOption('auto', '🤖 Automatic') + .addOption('whisper', '🎯 Whisper') + .addOption('deepgram', '🚀 Deepgram') + .setValue(this.state.get().currentProvider) + .onChange((value) => { + void this.handleProviderChange(value); + }); + }); // 자동 모드 전략 if (this.state.get().currentProvider === 'auto') { this.createSetting(section, '선택 전략', 'Provider 선택 방법').addDropdown( (dropdown) => { dropdown - .addOption(SelectionStrategy.PERFORMANCE_OPTIMIZED, '⚡ 성능 우선') - .addOption(SelectionStrategy.COST_OPTIMIZED, '💰 비용 최적화') - .addOption(SelectionStrategy.QUALITY_OPTIMIZED, '✨ 품질 우선') + .addOption(SelectionStrategy.PERFORMANCE_OPTIMIZED, '⚡ Performance first') + .addOption(SelectionStrategy.COST_OPTIMIZED, '💰 Cost optimized') + .addOption(SelectionStrategy.QUALITY_OPTIMIZED, '✨ Quality first') .setValue( this.plugin.settings.selectionStrategy || SelectionStrategy.PERFORMANCE_OPTIMIZED ) - .onChange((value) => this.handleStrategyChange(value)); + .onChange((value) => { + void this.handleStrategyChange(value); + }); } ); } @@ -296,12 +321,12 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { private renderProviderSelection(containerEl: HTMLElement): void { const section = this.createSection( containerEl, - 'Provider 선택', - 'Transcription Provider 구성' + 'Provider selection', + 'Transcription provider configuration' ); // Collapsible 섹션으로 구성 - const { contentEl } = UIComponentFactory.createCollapsibleSection( + UIComponentFactory.createCollapsibleSection( section, 'Provider 설정', this.state.get().isExpanded, @@ -327,10 +352,14 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { const actionsEl = containerEl.createDiv({ cls: 'quick-actions' }); const actions = [ - { text: '모든 키 검증', onClick: () => this.verifyAllApiKeys(), primary: true }, - { text: '연결 테스트', onClick: () => this.testAllConnections() }, - { text: '설정 내보내기', onClick: () => this.exportConfiguration() }, - { text: '설정 초기화', onClick: () => this.resetProviderSettings(), danger: true }, + { text: 'Verify all keys', onClick: () => void this.verifyAllApiKeys(), primary: true }, + { text: 'Test connection', onClick: () => void this.testAllConnections() }, + { text: 'Export config', onClick: () => void this.exportConfiguration() }, + { + text: 'Reset settings', + onClick: () => void this.resetProviderSettings(), + danger: true, + }, ]; actions.forEach((action) => { @@ -347,10 +376,10 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { * 메트릭 표시 */ private renderMetricsDisplay(containerEl: HTMLElement): void { - const section = this.createSection(containerEl, '성능 메트릭', '최근 30일간 통계'); + const _section = this.createSection(containerEl, '성능 메트릭', '최근 30일간 통계'); // 메트릭 데이터 (예시) - const metrics = this.memoized('metrics', () => this.calculateMetrics()); + const _metrics = this.memoized('metrics', () => this.calculateMetrics()); // 차트나 그래프로 표시 this.renderMetricsCharts(); @@ -373,20 +402,20 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { if (!this.memoCache.has(key)) { this.memoCache.set(key, compute()); } - return this.memoCache.get(key); + return this.memoCache.get(key) as T; } /** * 디바운스 헬퍼 */ - private debounce(key: string, fn: () => void, delay = 300): void { + private debounce(key: string, fn: () => void | Promise, delay = 300): void { const existing = this.debounceTimers.get(key); if (existing) { window.clearTimeout(existing); } const timer = window.setTimeout(() => { - fn(); + void fn(); this.debounceTimers.delete(key); }, delay); @@ -459,9 +488,9 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { private async checkAllConnectionsOptimized(): Promise { const providers: TranscriptionProvider[] = ['whisper', 'deepgram']; - const connectionPromises = providers.map(async (provider) => { + const connectionPromises = providers.map((provider) => { if (this.hasApiKey(provider)) { - const isConnected = await this.checkProviderConnection(provider); + const isConnected = this.checkProviderConnection(provider); return { provider, isConnected }; } return { provider, isConnected: false }; @@ -549,7 +578,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { * Provider 상태 텍스트 */ private getProviderStatusText( - provider: TranscriptionProvider, + _provider: TranscriptionProvider, hasKey: boolean, isConnected: boolean ): string { @@ -562,20 +591,22 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { * Provider 상세 정보 표시 */ private showProviderDetails(provider: TranscriptionProvider): void { + if (!this.app) return; // Modal로 상세 정보 표시 - const modal = new ProviderDetailsModal(this.app!, provider, this.plugin); + const modal = new ProviderDetailsModal(this.app, provider, this.plugin); modal.open(); } /** * Provider 테스트 */ - private async testProvider(provider: TranscriptionProvider): Promise { - await this.withErrorHandling(async () => { + private testProvider(provider: TranscriptionProvider): void { + void this.withErrorHandling(async () => { this.showNotice(`${this.getProviderDisplayName(provider)} 테스트 중...`); + await Promise.resolve(); // Ensure await // 테스트 로직 - const success = await this.checkProviderConnection(provider); + const success = this.checkProviderConnection(provider); if (success) { this.showNotice(`✅ ${this.getProviderDisplayName(provider)} 테스트 성공`); @@ -697,7 +728,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { /** * 메트릭 계산 */ - private calculateMetrics(): any { + private calculateMetrics(): ProviderMetrics { // 실제 메트릭 계산 로직 return { totalRequests: 0, @@ -744,14 +775,15 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { } private isSelectionStrategy(value: string): value is SelectionStrategy { - return ( - value === SelectionStrategy.MANUAL || - value === SelectionStrategy.COST_OPTIMIZED || - value === SelectionStrategy.PERFORMANCE_OPTIMIZED || - value === SelectionStrategy.QUALITY_OPTIMIZED || - value === SelectionStrategy.ROUND_ROBIN || - value === SelectionStrategy.AB_TEST - ); + const strategies: string[] = [ + SelectionStrategy.MANUAL, + SelectionStrategy.COST_OPTIMIZED, + SelectionStrategy.PERFORMANCE_OPTIMIZED, + SelectionStrategy.QUALITY_OPTIMIZED, + SelectionStrategy.ROUND_ROBIN, + SelectionStrategy.AB_TEST, + ]; + return strategies.includes(value); } } @@ -768,7 +800,8 @@ class ProviderDetailsModal extends Modal { } onOpen(): void { - const { contentEl, titleEl } = this; + const { titleEl } = this; + const contentEl = this.contentEl; titleEl.setText(`${this.getProviderName()} 상세 정보`); diff --git a/src/ui/settings/settings.css b/src/ui/settings/settings.css index 0f5a83a..4cec3b3 100644 --- a/src/ui/settings/settings.css +++ b/src/ui/settings/settings.css @@ -15,4 +15,3 @@ .provider-settings { display: block; } - diff --git a/src/ui/settings/types/SettingsTypes.ts b/src/ui/settings/types/SettingsTypes.ts index 0dd73fd..3047205 100644 --- a/src/ui/settings/types/SettingsTypes.ts +++ b/src/ui/settings/types/SettingsTypes.ts @@ -39,7 +39,7 @@ export interface ProviderStatus { /** * 검증 결과 */ -export interface ValidationResult { +export interface ValidationResult { isValid: boolean; errors: string[]; warnings: string[]; @@ -49,7 +49,7 @@ export interface ValidationResult { /** * 설정 변경 이벤트 */ -export interface SettingsChangeEvent { +export interface SettingsChangeEvent { key: string; oldValue: T; newValue: T; @@ -75,15 +75,7 @@ export interface MetricsData { /** * 설정 값 타입 */ -export type SettingValue = - | string - | number - | boolean - | Date - | TranscriptionProvider - | SelectionStrategy - | Record - | unknown[]; +export type SettingValue = string | number | boolean | Date | Record | unknown[]; /** * 설정 키 타입 (type-safe keys) @@ -186,14 +178,14 @@ export type DeepReadonly = { * Required Keys 타입 */ export type RequiredKeys = { - [K in keyof T]-?: {} extends Pick ? never : K; + [K in keyof T]-?: object extends Pick ? never : K; }[keyof T]; /** * Optional Keys 타입 */ export type OptionalKeys = { - [K in keyof T]-?: {} extends Pick ? K : never; + [K in keyof T]-?: object extends Pick ? K : never; }[keyof T]; /** @@ -288,8 +280,10 @@ export class SettingsChangeTracker> { new: newValue, }); } else { - const change = this.changes.get(key)!; - change.new = newValue; + const change = this.changes.get(key); + if (change) { + change.new = newValue; + } } } @@ -337,7 +331,10 @@ export class SettingsValidator> { if (!this.rules.has(fieldKey)) { this.rules.set(fieldKey, []); } - this.rules.get(fieldKey)!.push((value: unknown) => validator(value as T[K])); + const fieldRules = this.rules.get(fieldKey); + if (fieldRules) { + fieldRules.push((value: unknown) => validator(value as T[K])); + } return this; } @@ -400,7 +397,7 @@ export function safeJsonParse(json: string, defaultValue: T): T { /** * 깊은 병합 */ -export function deepMerge>(target: T, source: Partial): T { +export function deepMerge>(target: T, source: Partial): T { const result = { ...target }; for (const key in source) { @@ -408,9 +405,12 @@ export function deepMerge>(target: T, source: Part if (sourceValue !== undefined) { const targetValue = target[key]; if (isPlainRecord(sourceValue) && isPlainRecord(targetValue)) { - result[key] = deepMerge(targetValue as T[typeof key], sourceValue as T[typeof key]); + result[key] = deepMerge( + targetValue as Record, + sourceValue as Record + ) as T[Extract]; } else { - result[key] = sourceValue as T[typeof key]; + result[key] = sourceValue as T[Extract]; } } } diff --git a/src/ui/styles/file-picker.css b/src/ui/styles/file-picker.css index 25f232a..3ea644f 100644 --- a/src/ui/styles/file-picker.css +++ b/src/ui/styles/file-picker.css @@ -556,8 +556,12 @@ } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } .progress-bar-container { diff --git a/src/ui/styles/notifications.css b/src/ui/styles/notifications.css index f001bd4..0c69a5a 100644 --- a/src/ui/styles/notifications.css +++ b/src/ui/styles/notifications.css @@ -236,13 +236,21 @@ } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes fadeOut { - from { opacity: 1; } - to { opacity: 0; } + from { + opacity: 1; + } + to { + opacity: 0; + } } .modal { @@ -405,8 +413,12 @@ } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } .loading-spinner--small svg { @@ -457,7 +469,8 @@ } @keyframes pulse { - 0%, 100% { + 0%, + 100% { opacity: 0.3; transform: scale(0.8); } @@ -501,12 +514,7 @@ left: -100%; width: 100%; height: 100%; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.2), - transparent - ); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); animation: shimmer 2s infinite; } @@ -577,7 +585,9 @@ } @keyframes dot-bounce { - 0%, 60%, 100% { + 0%, + 60%, + 100% { transform: translateY(0); } 30% { diff --git a/src/ui/styles/progress.css b/src/ui/styles/progress.css index fdd79eb..c16463e 100644 --- a/src/ui/styles/progress.css +++ b/src/ui/styles/progress.css @@ -11,24 +11,24 @@ --color-warning: #fd7e14; --color-error: #dc3545; --color-info: #0dcaf0; - + /* 배경색 */ --bg-primary: var(--background-primary); --bg-secondary: var(--background-secondary); --bg-overlay: rgba(0, 0, 0, 0.5); - + /* 텍스트 색상 */ --text-primary: var(--text-normal); --text-secondary: var(--text-muted); --text-on-primary: #ffffff; - + /* 간격 */ --spacing-xs: 4px; --spacing-sm: 8px; --spacing-md: 16px; --spacing-lg: 24px; --spacing-xl: 32px; - + /* 애니메이션 */ --transition-fast: 150ms ease; --transition-normal: 300ms ease; @@ -74,8 +74,12 @@ } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } /* 펄스 로더 */ @@ -106,7 +110,9 @@ } @keyframes pulse { - 0%, 80%, 100% { + 0%, + 80%, + 100% { opacity: 0.3; transform: scale(1); } @@ -170,8 +176,12 @@ } @keyframes skeleton-loading { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } } /* 도트 로더 */ @@ -196,11 +206,17 @@ animation: dot-bounce 1.4s ease-in-out infinite both; } -.dots-container .dot:nth-child(1) { animation-delay: -0.32s; } -.dots-container .dot:nth-child(2) { animation-delay: -0.16s; } +.dots-container .dot:nth-child(1) { + animation-delay: -0.32s; +} +.dots-container .dot:nth-child(2) { + animation-delay: -0.16s; +} @keyframes dot-bounce { - 0%, 80%, 100% { + 0%, + 80%, + 100% { transform: scale(0.8); opacity: 0.5; } @@ -222,10 +238,18 @@ height: 24px; } -.status-icon--success { color: var(--color-success); } -.status-icon--error { color: var(--color-error); } -.status-icon--warning { color: var(--color-warning); } -.status-icon--info { color: var(--color-info); } +.status-icon--success { + color: var(--color-success); +} +.status-icon--error { + color: var(--color-error); +} +.status-icon--warning { + color: var(--color-warning); +} +.status-icon--info { + color: var(--color-info); +} /* ===== 진행률 바 ===== */ @@ -248,8 +272,12 @@ overflow: hidden; } -.progress-bar--small .progress-bar__container { height: 4px; } -.progress-bar--large .progress-bar__container { height: 12px; } +.progress-bar--small .progress-bar__container { + height: 4px; +} +.progress-bar--large .progress-bar__container { + height: 12px; +} .progress-bar__fill { height: 100%; @@ -258,9 +286,15 @@ border-radius: 4px; } -.progress-bar--success .progress-bar__fill { background-color: var(--color-success); } -.progress-bar--warning .progress-bar__fill { background-color: var(--color-warning); } -.progress-bar--error .progress-bar__fill { background-color: var(--color-error); } +.progress-bar--success .progress-bar__fill { + background-color: var(--color-success); +} +.progress-bar--warning .progress-bar__fill { + background-color: var(--color-warning); +} +.progress-bar--error .progress-bar__fill { + background-color: var(--color-error); +} /* 줄무늬 효과 */ .progress-bar__fill--striped { @@ -279,8 +313,12 @@ } @keyframes progress-striped { - from { background-position: 40px 0; } - to { background-position: 0 0; } + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } } /* Indeterminate 모드 */ @@ -290,8 +328,12 @@ } @keyframes progress-indeterminate { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(400%); } + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } } .progress-bar__info { @@ -353,9 +395,15 @@ } @keyframes pulse-ring { - 0% { box-shadow: 0 0 0 0 rgba(94, 129, 172, 0.7); } - 70% { box-shadow: 0 0 0 10px rgba(94, 129, 172, 0); } - 100% { box-shadow: 0 0 0 0 rgba(94, 129, 172, 0); } + 0% { + box-shadow: 0 0 0 0 rgba(94, 129, 172, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(94, 129, 172, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(94, 129, 172, 0); + } } .step--error .step__number { @@ -1179,29 +1227,29 @@ textarea:focus-visible, .toast-container { padding: var(--spacing-sm); } - + .toast { min-width: 250px; max-width: calc(100vw - 32px); } - + .modal-notification { min-width: 90%; max-width: 90%; } - + .dashboard__stats-grid { grid-template-columns: 1fr; } - + .dashboard__charts { grid-template-columns: 1fr; } - + .history__table { font-size: 0.85em; } - + .history__table th, .history__table td { padding: var(--spacing-xs); @@ -1214,17 +1262,17 @@ textarea:focus-visible, gap: var(--spacing-md); align-items: flex-start; } - + .dashboard__controls { width: 100%; flex-wrap: wrap; } - + .dashboard__controls button { flex: 1; min-width: 100px; } - + .history__table { display: block; overflow-x: auto; diff --git a/src/ui/styles/provider-settings.css b/src/ui/styles/provider-settings.css index 6bbb803..182bdc6 100644 --- a/src/ui/styles/provider-settings.css +++ b/src/ui/styles/provider-settings.css @@ -91,9 +91,15 @@ } /* Status Levels */ -.status-good { color: var(--text-success, #4caf50); } -.status-warning { color: var(--text-warning, #ff9800); } -.status-error { color: var(--text-error, #f44336); } +.status-good { + color: var(--text-success, #4caf50); +} +.status-warning { + color: var(--text-warning, #ff9800); +} +.status-error { + color: var(--text-error, #f44336); +} /* Providers Grid */ .providers-status-grid { @@ -140,10 +146,18 @@ transition: all 0.2s; } -.indicator.has-key { background: rgba(76, 175, 80, 0.1); } -.indicator.no-key { background: rgba(244, 67, 54, 0.1); } -.indicator.connected { background: rgba(76, 175, 80, 0.1); } -.indicator.disconnected { background: rgba(255, 152, 0, 0.1); } +.indicator.has-key { + background: rgba(76, 175, 80, 0.1); +} +.indicator.no-key { + background: rgba(244, 67, 54, 0.1); +} +.indicator.connected { + background: rgba(76, 175, 80, 0.1); +} +.indicator.disconnected { + background: rgba(255, 152, 0, 0.1); +} /* Last Update */ .last-update { @@ -192,9 +206,15 @@ transition: width 0.3s ease; } -.weight-segment.latency { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } -.weight-segment.success { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); } -.weight-segment.cost { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); } +.weight-segment.latency { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} +.weight-segment.success { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); +} +.weight-segment.cost { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); +} /* === API Key Section === */ .api-key-section { @@ -251,11 +271,25 @@ font-size: 0.85em; } -.key-status-indicator.status-empty { background: var(--background-modifier-border); } -.key-status-indicator.status-valid { background: rgba(76, 175, 80, 0.1); color: var(--text-success); } -.key-status-indicator.status-invalid { background: rgba(244, 67, 54, 0.1); color: var(--text-error); } -.key-status-indicator.status-checking { background: rgba(33, 150, 243, 0.1); color: var(--text-accent); } -.key-status-indicator.status-unverified { background: rgba(255, 152, 0, 0.1); color: var(--text-warning); } +.key-status-indicator.status-empty { + background: var(--background-modifier-border); +} +.key-status-indicator.status-valid { + background: rgba(76, 175, 80, 0.1); + color: var(--text-success); +} +.key-status-indicator.status-invalid { + background: rgba(244, 67, 54, 0.1); + color: var(--text-error); +} +.key-status-indicator.status-checking { + background: rgba(33, 150, 243, 0.1); + color: var(--text-accent); +} +.key-status-indicator.status-unverified { + background: rgba(255, 152, 0, 0.1); + color: var(--text-warning); +} /* Input Group */ .key-input-group { @@ -533,7 +567,8 @@ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1); } -.split-a, .split-b { +.split-a, +.split-b { display: flex; align-items: center; justify-content: center; @@ -685,24 +720,24 @@ .providers-status-grid { grid-template-columns: 1fr; } - + .status-grid, .stats-grid { grid-template-columns: 1fr; } - + .provider-actions { flex-direction: column; } - + .api-key-actions { flex-direction: column; } - + .key-input-group { flex-wrap: wrap; } - + .api-key-input { width: 100%; } @@ -791,7 +826,7 @@ .provider-settings-container { --background-modifier-border: #000; } - + .api-key-input { border-width: 2px; } diff --git a/styles/provider-settings.css b/styles/provider-settings.css index 1cf31dc..7831411 100644 --- a/styles/provider-settings.css +++ b/styles/provider-settings.css @@ -75,7 +75,7 @@ border-color: var(--text-error); } -.api-key-input[data-has-value="true"] { +.api-key-input[data-has-value='true'] { background-color: var(--background-secondary); } @@ -305,15 +305,15 @@ .api-key-input { min-width: 200px; } - + .stat-grid { grid-template-columns: 1fr 1fr; } - + .chart-bars { height: 120px; } - + .chart-bar { width: 60px; } @@ -375,4 +375,4 @@ select:focus-visible { .setting-item:hover .tooltip { opacity: 1; -} \ No newline at end of file +} diff --git a/tests/e2e/error-handling.e2e.test.ts b/tests/e2e/error-handling.e2e.test.ts index 02850ea..d2e6527 100644 --- a/tests/e2e/error-handling.e2e.test.ts +++ b/tests/e2e/error-handling.e2e.test.ts @@ -1,6 +1,6 @@ /** * E2E Test: 에러 처리 시나리오 - * + * * 테스트 시나리오: * 1. 네트워크 에러 처리 * 2. API 에러 응답 처리 @@ -39,15 +39,15 @@ describe('E2E: 에러 처리 시나리오', () => { app = { workspace: { getActiveViewOfType: jest.fn(), - trigger: jest.fn() + trigger: jest.fn(), }, vault: { adapter: { read: jest.fn(), write: jest.fn(), - exists: jest.fn().mockResolvedValue(true) - } - } + exists: jest.fn().mockResolvedValue(true), + }, + }, } as any; // 기본 설정 @@ -68,7 +68,7 @@ describe('E2E: 에러 처리 시나리오', () => { retryAttempts: 3, retryDelay: 1000, timeout: 30000, - concurrentLimit: 3 + concurrentLimit: 3, }; // 서비스 초기화 @@ -114,7 +114,7 @@ describe('E2E: 에러 처리 시나리오', () => { } return Promise.resolve({ ok: true, - text: () => Promise.resolve('변환된 텍스트') + text: () => Promise.resolve('변환된 텍스트'), }); }); @@ -126,7 +126,7 @@ describe('E2E: 에러 처리 시나리오', () => { // 검증 expect(attemptCount).toBe(3); // 2번 실패 후 3번째 성공 expect(result.text).toBe('변환된 텍스트'); - + // 로그 확인 const logSpy = jest.spyOn(logger, 'warn'); logger.warn(`Retry attempt ${attemptCount}/${maxRetries}`); @@ -150,7 +150,7 @@ describe('E2E: 에러 처리 시나리오', () => { try { await transcriptionService.transcribe(mockFile, { - signal: controller.signal + signal: controller.signal, }); fail('Should have thrown timeout error'); } catch (error: any) { @@ -166,9 +166,9 @@ describe('E2E: 에러 처리 시나리오', () => { test('CORS 에러 처리', async () => { // CORS 에러 시뮬레이션 - (global.fetch as jest.Mock) = jest.fn().mockRejectedValue( - new TypeError('Failed to fetch') - ); + (global.fetch as jest.Mock) = jest + .fn() + .mockRejectedValue(new TypeError('Failed to fetch')); const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' }); @@ -192,12 +192,13 @@ describe('E2E: 에러 처리 시나리오', () => { ok: false, status: 401, statusText: 'Unauthorized', - json: () => Promise.resolve({ - error: { - message: 'Invalid API key provided', - type: 'invalid_request_error' - } - }) + json: () => + Promise.resolve({ + error: { + message: 'Invalid API key provided', + type: 'invalid_request_error', + }, + }), }); const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' }); @@ -227,19 +228,20 @@ describe('E2E: 에러 처리 시나리오', () => { status: 429, statusText: 'Too Many Requests', headers: new Headers({ - 'Retry-After': retryAfter.toString() + 'Retry-After': retryAfter.toString(), }), - json: () => Promise.resolve({ - error: { - message: 'Rate limit exceeded', - type: 'rate_limit_error' - } - }) + json: () => + Promise.resolve({ + error: { + message: 'Rate limit exceeded', + type: 'rate_limit_error', + }, + }), }); } return Promise.resolve({ ok: true, - text: () => Promise.resolve('변환된 텍스트') + text: () => Promise.resolve('변환된 텍스트'), }); }); @@ -250,7 +252,7 @@ describe('E2E: 에러 처리 시나리오', () => { await whisperService.transcribe(mockFile); } catch (error: any) { expect(error.message).toContain('Rate limit'); - + // Retry-After 헤더 파싱 const waitTime = parseInt(error.retryAfter || '60'); expect(waitTime).toBe(60); @@ -265,19 +267,18 @@ describe('E2E: 에러 처리 시나리오', () => { ok: false, status: 413, statusText: 'Payload Too Large', - json: () => Promise.resolve({ - error: { - message: 'Maximum file size exceeded', - type: 'invalid_request_error' - } - }) + json: () => + Promise.resolve({ + error: { + message: 'Maximum file size exceeded', + type: 'invalid_request_error', + }, + }), }); - const largeFile = new File( - [new ArrayBuffer(30 * 1024 * 1024)], - 'large.mp3', - { type: 'audio/mp3' } - ); + const largeFile = new File([new ArrayBuffer(30 * 1024 * 1024)], 'large.mp3', { + type: 'audio/mp3', + }); try { await whisperService.transcribe(largeFile); @@ -302,17 +303,18 @@ describe('E2E: 에러 처리 시나리오', () => { ok: false, status: 500, statusText: 'Internal Server Error', - json: () => Promise.resolve({ - error: { - message: 'An error occurred during processing', - type: 'server_error' - } - }) + json: () => + Promise.resolve({ + error: { + message: 'An error occurred during processing', + type: 'server_error', + }, + }), }); } return Promise.resolve({ ok: true, - text: () => Promise.resolve('변환된 텍스트') + text: () => Promise.resolve('변환된 텍스트'), }); }); @@ -328,11 +330,9 @@ describe('E2E: 에러 처리 시나리오', () => { describe('파일 처리 에러', () => { test('지원하지 않는 파일 형식', async () => { - const unsupportedFile = new File( - ['text content'], - 'document.txt', - { type: 'text/plain' } - ); + const unsupportedFile = new File(['text content'], 'document.txt', { + type: 'text/plain', + }); try { await transcriptionService.transcribe(unsupportedFile); @@ -352,19 +352,18 @@ describe('E2E: 에러 처리 시나리오', () => { ok: false, status: 400, statusText: 'Bad Request', - json: () => Promise.resolve({ - error: { - message: 'Invalid audio file', - type: 'invalid_request_error' - } - }) + json: () => + Promise.resolve({ + error: { + message: 'Invalid audio file', + type: 'invalid_request_error', + }, + }), }); - const corruptedFile = new File( - ['corrupted data'], - 'corrupted.mp3', - { type: 'audio/mp3' } - ); + const corruptedFile = new File(['corrupted data'], 'corrupted.mp3', { + type: 'audio/mp3', + }); try { await whisperService.transcribe(corruptedFile); @@ -379,11 +378,9 @@ describe('E2E: 에러 처리 시나리오', () => { test('파일 읽기 권한 에러', async () => { const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' }); - + // 파일 읽기 에러 시뮬레이션 - jest.spyOn(mockFile, 'arrayBuffer').mockRejectedValue( - new Error('Permission denied') - ); + jest.spyOn(mockFile, 'arrayBuffer').mockRejectedValue(new Error('Permission denied')); try { await transcriptionService.transcribe(mockFile); @@ -409,7 +406,7 @@ describe('E2E: 에러 처리 시나리오', () => { } return Promise.resolve({ ok: true, - text: () => Promise.resolve('변환된 텍스트') + text: () => Promise.resolve('변환된 텍스트'), }); }); @@ -433,7 +430,7 @@ describe('E2E: 에러 처리 시나리오', () => { const files = [ new File(['audio1'], 'file1.mp3', { type: 'audio/mp3' }), new File(['audio2'], 'file2.mp3', { type: 'audio/mp3' }), - new File(['audio3'], 'file3.mp3', { type: 'audio/mp3' }) + new File(['audio3'], 'file3.mp3', { type: 'audio/mp3' }), ]; let callCount = 0; @@ -445,7 +442,7 @@ describe('E2E: 에러 처리 시나리오', () => { } return Promise.resolve({ ok: true, - text: () => Promise.resolve(`변환된 텍스트 ${callCount}`) + text: () => Promise.resolve(`변환된 텍스트 ${callCount}`), }); }); @@ -477,9 +474,7 @@ describe('E2E: 에러 처리 시나리오', () => { // 에러 리포터 등록 errorManager.setReporter(errorReportSpy); - (global.fetch as jest.Mock) = jest.fn().mockRejectedValue( - new Error('Critical error') - ); + (global.fetch as jest.Mock) = jest.fn().mockRejectedValue(new Error('Critical error')); const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' }); @@ -494,7 +489,7 @@ describe('E2E: 에러 처리 시나리오', () => { await errorManager.report(error, { context: 'transcription', file: mockFile.name, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); expect(errorReportSpy).toHaveBeenCalled(); } @@ -513,7 +508,7 @@ describe('E2E: 에러 처리 시나리오', () => { } return Promise.resolve({ ok: true, - text: () => Promise.resolve('폴백 API 결과') + text: () => Promise.resolve('폴백 API 결과'), }); }); @@ -539,7 +534,7 @@ describe('E2E: 에러 처리 시나리오', () => { { error: 'ETIMEDOUT', message: '연결 시간이 초과되었습니다.' }, { error: 'ENOTFOUND', message: '서버를 찾을 수 없습니다.' }, { error: 'EPERM', message: '권한이 없습니다.' }, - { error: 'ENOSPC', message: '저장 공간이 부족합니다.' } + { error: 'ENOSPC', message: '저장 공간이 부족합니다.' }, ]; technicalErrors.forEach(({ error, message }) => { @@ -552,17 +547,14 @@ describe('E2E: 에러 처리 시나리오', () => { const errorWithSolution = { code: 'INVALID_API_KEY', message: 'API 키가 유효하지 않습니다.', - solution: '설정에서 올바른 API 키를 입력해주세요.' + solution: '설정에서 올바른 API 키를 입력해주세요.', }; const solution = errorHandler.getSolution(errorWithSolution.code); expect(solution).toBe(errorWithSolution.solution); // 해결 방법 표시 - notificationManager.error( - errorWithSolution.message, - { detail: solution } - ); + notificationManager.error(errorWithSolution.message, { detail: solution }); }); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/file-conversion-flow.e2e.test.ts b/tests/e2e/file-conversion-flow.e2e.test.ts index dd03089..ea246dc 100644 --- a/tests/e2e/file-conversion-flow.e2e.test.ts +++ b/tests/e2e/file-conversion-flow.e2e.test.ts @@ -1,6 +1,6 @@ /** * E2E Test: 파일 선택 → 변환 → 삽입 전체 플로우 - * + * * 테스트 시나리오: * 1. 파일 선택 모달 열기 * 2. 오디오 파일 선택 @@ -45,17 +45,17 @@ describe('E2E: 파일 변환 플로우', () => { setCursor: jest.fn(), getLine: jest.fn().mockReturnValue(''), lastLine: jest.fn().mockReturnValue(0), - getSelection: jest.fn().mockReturnValue('') - } - }) + getSelection: jest.fn().mockReturnValue(''), + }, + }), }, vault: { adapter: { read: jest.fn(), write: jest.fn(), - exists: jest.fn().mockResolvedValue(true) - } - } + exists: jest.fn().mockResolvedValue(true), + }, + }, } as any; editor = app.workspace.getActiveViewOfType(null).editor; @@ -78,7 +78,7 @@ describe('E2E: 파일 변환 플로우', () => { retryAttempts: 3, retryDelay: 1000, timeout: 30000, - concurrentLimit: 3 + concurrentLimit: 3, }; // 서비스 초기화 @@ -88,12 +88,12 @@ describe('E2E: 파일 변환 플로우', () => { progressTracker = new ProgressTracker('file-conversion', stateManager); editorService = new EditorService(app); transcriptionService = new TranscriptionService(settings); - + // Mock API 응답 global.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue({ text: '변환된 텍스트' }), - text: jest.fn().mockResolvedValue('변환된 텍스트') + text: jest.fn().mockResolvedValue('변환된 텍스트'), }); }); @@ -132,7 +132,7 @@ describe('E2E: 파일 변환 플로우', () => { await editorService.insertText(result.text, { position: settings.insertPosition, addTimestamp: settings.addTimestamp, - timestampFormat: settings.timestampFormat + timestampFormat: settings.timestampFormat, }); progressTracker.increment(); @@ -149,11 +149,7 @@ describe('E2E: 파일 변환 플로우', () => { ); // 테스트용 파일 생성 - const mockFile = new File( - ['mock audio data'], - 'test-audio.mp3', - { type: 'audio/mp3' } - ); + const mockFile = new File(['mock audio data'], 'test-audio.mp3', { type: 'audio/mp3' }); // 파일 선택 시뮬레이션 await filePickerModal.onChooseFile(mockFile); @@ -164,8 +160,8 @@ describe('E2E: 파일 변환 플로우', () => { expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ - 'Authorization': `Bearer ${settings.apiKey}` - }) + Authorization: `Bearer ${settings.apiKey}`, + }), }) ); }); @@ -177,7 +173,7 @@ describe('E2E: 파일 변환 플로우', () => { // 드래그 이벤트 시뮬레이션 const dragEnterEvent = new DragEvent('dragenter', { - dataTransfer: new DataTransfer() + dataTransfer: new DataTransfer(), }); dropZone.dispatchEvent(dragEnterEvent); expect(dropZone.classList.contains('drag-over')).toBe(false); // 실제 구현에서 추가 필요 @@ -186,16 +182,16 @@ describe('E2E: 파일 변환 플로우', () => { const mockFile = new File(['mock audio'], 'audio.wav', { type: 'audio/wav' }); const dataTransfer = new DataTransfer(); dataTransfer.items.add(mockFile); - + const dropEvent = new DragEvent('drop', { - dataTransfer: dataTransfer + dataTransfer: dataTransfer, }); // 드롭 핸들러 등록 dropZone.addEventListener('drop', async (e: DragEvent) => { e.preventDefault(); const files = Array.from(e.dataTransfer?.files || []); - + for (const file of files) { // 변환 프로세스 시작 const result = await transcriptionService.transcribe(file); @@ -207,11 +203,11 @@ describe('E2E: 파일 변환 플로우', () => { dropZone.dispatchEvent(dropEvent); // 변환 완료 대기 - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); // 검증 expect(global.fetch).toHaveBeenCalled(); - + document.body.removeChild(dropZone); }); @@ -219,7 +215,7 @@ describe('E2E: 파일 변환 플로우', () => { const files = [ new File(['audio1'], 'file1.mp3', { type: 'audio/mp3' }), new File(['audio2'], 'file2.wav', { type: 'audio/wav' }), - new File(['audio3'], 'file3.m4a', { type: 'audio/m4a' }) + new File(['audio3'], 'file3.m4a', { type: 'audio/m4a' }), ]; let processedCount = 0; @@ -230,7 +226,7 @@ describe('E2E: 파일 변환 플로우', () => { processedCount++; return Promise.resolve({ ok: true, - text: () => Promise.resolve(`변환된 텍스트 ${processedCount}`) + text: () => Promise.resolve(`변환된 텍스트 ${processedCount}`), }); }); @@ -238,10 +234,10 @@ describe('E2E: 파일 변환 플로우', () => { for (const file of files) { progressTracker.start(files.length); progressTracker.updateMessage(`${file.name} 처리 중...`); - + const result = await transcriptionService.transcribe(file); results.push(result.text); - + await editorService.insertText(result.text); progressTracker.increment(); } @@ -250,11 +246,7 @@ describe('E2E: 파일 변환 플로우', () => { // 검증 expect(results).toHaveLength(3); - expect(results).toEqual([ - '변환된 텍스트 1', - '변환된 텍스트 2', - '변환된 텍스트 3' - ]); + expect(results).toEqual(['변환된 텍스트 1', '변환된 텍스트 2', '변환된 텍스트 3']); expect(editor.replaceSelection).toHaveBeenCalledTimes(3); }); }); @@ -266,8 +258,9 @@ describe('E2E: 파일 변환 플로우', () => { const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' }); - await expect(transcriptionService.transcribe(mockFile)) - .rejects.toThrow('API key is required'); + await expect(transcriptionService.transcribe(mockFile)).rejects.toThrow( + 'API key is required' + ); // 에러 알림 확인 const errorSpy = jest.spyOn(notificationManager, 'error'); @@ -282,8 +275,9 @@ describe('E2E: 파일 변환 플로우', () => { { type: 'audio/mp3' } ); - await expect(transcriptionService.transcribe(largeFile)) - .rejects.toThrow(`File size exceeds maximum limit of ${settings.maxFileSize}MB`); + await expect(transcriptionService.transcribe(largeFile)).rejects.toThrow( + `File size exceeds maximum limit of ${settings.maxFileSize}MB` + ); }); test('네트워크 에러 처리 및 재시도', async () => { @@ -295,7 +289,7 @@ describe('E2E: 파일 변환 플로우', () => { } return Promise.resolve({ ok: true, - text: () => Promise.resolve('변환된 텍스트') + text: () => Promise.resolve('변환된 텍스트'), }); }); @@ -308,26 +302,27 @@ describe('E2E: 파일 변환 플로우', () => { test('타임아웃 처리', async () => { (global.fetch as jest.Mock).mockImplementation( - () => new Promise(resolve => { - setTimeout(resolve, settings.timeout + 1000); - }) + () => + new Promise((resolve) => { + setTimeout(resolve, settings.timeout + 1000); + }) ); const mockFile = new File(['audio'], 'test.mp3', { type: 'audio/mp3' }); - await expect(transcriptionService.transcribe(mockFile)) - .rejects.toThrow('Request timeout'); + await expect(transcriptionService.transcribe(mockFile)).rejects.toThrow( + 'Request timeout' + ); }); test('잘못된 파일 형식', async () => { - const invalidFile = new File( - ['not audio'], - 'document.pdf', - { type: 'application/pdf' } - ); + const invalidFile = new File(['not audio'], 'document.pdf', { + type: 'application/pdf', + }); - await expect(transcriptionService.transcribe(invalidFile)) - .rejects.toThrow('Unsupported file type'); + await expect(transcriptionService.transcribe(invalidFile)).rejects.toThrow( + 'Unsupported file type' + ); }); }); @@ -355,12 +350,11 @@ describe('E2E: 파일 변환 플로우', () => { let isCancelled = false; const abortController = new AbortController(); - + // 변환 시작 - const transcribePromise = transcriptionService.transcribe( - mockFile, - { signal: abortController.signal } - ); + const transcribePromise = transcriptionService.transcribe(mockFile, { + signal: abortController.signal, + }); // 취소 setTimeout(() => { @@ -372,4 +366,4 @@ describe('E2E: 파일 변환 플로우', () => { expect(isCancelled).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/settings-flow.e2e.test.ts b/tests/e2e/settings-flow.e2e.test.ts index 346d9c5..36d887b 100644 --- a/tests/e2e/settings-flow.e2e.test.ts +++ b/tests/e2e/settings-flow.e2e.test.ts @@ -1,6 +1,6 @@ /** * E2E Test: 설정 변경 및 저장 플로우 - * + * * 테스트 시나리오: * 1. 설정 탭 열기 * 2. 각 설정 항목 변경 @@ -30,7 +30,14 @@ interface MockUIOptions { } function renderMockSettingsUI(options: MockUIOptions): void { - const { containerEl, plugin, settingsManager, notificationManager, defaultSettings, originalSuccess } = options; + const { + containerEl, + plugin, + settingsManager, + notificationManager, + defaultSettings, + originalSuccess, + } = options; containerEl.innerHTML = ''; @@ -98,7 +105,7 @@ function renderMockSettingsUI(options: MockUIOptions): void { const responseSection = createSection('Response Format', 'response-section'); const formats: Array<{ value: string; label: string }> = [ { value: 'text', label: 'Text' }, - { value: 'json', label: 'JSON' } + { value: 'json', label: 'JSON' }, ]; const timestampOption = document.createElement('div'); timestampOption.className = 'timestamp-option'; @@ -298,12 +305,12 @@ describe('E2E: 설정 플로우', () => { adapter: { read: jest.fn(), write: jest.fn(), - exists: jest.fn().mockResolvedValue(true) - } + exists: jest.fn().mockResolvedValue(true), + }, }, workspace: { - trigger: jest.fn() - } + trigger: jest.fn(), + }, } as any; // Mock Plugin @@ -311,7 +318,7 @@ describe('E2E: 설정 플로우', () => { manifest: { id: 'speech-to-text', name: 'Speech to Text', - version: '2.0.0' + version: '2.0.0', }, settings: { apiKey: '', @@ -330,10 +337,10 @@ describe('E2E: 설정 플로우', () => { retryAttempts: 3, retryDelay: 1000, timeout: 30000, - concurrentLimit: 3 + concurrentLimit: 3, }, saveData: jest.fn(), - loadData: jest.fn() + loadData: jest.fn(), }; // 서비스 초기화 @@ -341,17 +348,18 @@ describe('E2E: 설정 플로우', () => { settingsValidator = new SettingsValidator(); settingsMigrator = { migrate: jest.fn(async (settings: any, fromVersion = '2.0.0', toVersion = '3.0.0') => { - const normalizedLanguage = (settings.language || 'auto').toString().toLowerCase() === 'korean' - ? 'ko' - : settings.language || 'auto'; + const normalizedLanguage = + (settings.language || 'auto').toString().toLowerCase() === 'korean' + ? 'ko' + : settings.language || 'auto'; return { ...settings, version: toVersion, language: normalizedLanguage, maxFileSize: settings.fileSize ?? settings.maxFileSize ?? 25, - enableNotifications: settings.enableNotifications ?? true + enableNotifications: settings.enableNotifications ?? true, }; - }) + }), } as unknown as SettingsMigrator; settingsManager = { saveSettings: jest.fn(async (newSettings: any) => { @@ -364,11 +372,11 @@ describe('E2E: 설정 플로우', () => { plugin.settings = { ...plugin.settings, ...data }; } return plugin.settings; - }) + }), } as unknown as SettingsManager; notificationManager = new NotificationManager({ defaultDuration: 5000, - soundEnabled: true + soundEnabled: true, }); settingsTab = new SettingsTab(app, plugin); @@ -377,7 +385,7 @@ describe('E2E: 설정 플로우', () => { Object.defineProperty(settingsTab, 'containerEl', { value: containerEl, - writable: true + writable: true, }); settingsTab.display = () => @@ -388,7 +396,7 @@ describe('E2E: 설정 플로우', () => { notificationManager, defaultSettings, eventManager, - originalSuccess + originalSuccess, }); }); @@ -443,15 +451,19 @@ describe('E2E: 설정 플로우', () => { test('API 키 입력 및 검증', async () => { settingsTab.display(); - const apiKeyInput = containerEl.querySelector('input[type="password"]') as HTMLInputElement; - const validateButton = containerEl.querySelector('button.validate-api-key') as HTMLButtonElement; + const apiKeyInput = containerEl.querySelector( + 'input[type="password"]' + ) as HTMLInputElement; + const validateButton = containerEl.querySelector( + 'button.validate-api-key' + ) as HTMLButtonElement; // 빈 API 키 검증 apiKeyInput.value = ''; validateButton.click(); - - await new Promise(resolve => setTimeout(resolve, 100)); - + + await new Promise((resolve) => setTimeout(resolve, 100)); + let errorMessage = containerEl.querySelector('.error-message'); expect(errorMessage?.textContent).toContain('API 키를 입력해주세요'); @@ -462,11 +474,11 @@ describe('E2E: 설정 플로우', () => { // Mock API 검증 성공 global.fetch = jest.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ models: ['whisper-1'] }) + json: () => Promise.resolve({ models: ['whisper-1'] }), }); validateButton.click(); - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); const successMessage = containerEl.querySelector('.success-message'); expect(successMessage?.textContent).toContain('API 키가 유효합니다'); @@ -475,8 +487,10 @@ describe('E2E: 설정 플로우', () => { test('언어 설정 변경', () => { settingsTab.display(); - const languageSelect = containerEl.querySelector('select.language-select') as HTMLSelectElement; - + const languageSelect = containerEl.querySelector( + 'select.language-select' + ) as HTMLSelectElement; + // 초기값 확인 expect(languageSelect.value).toBe('auto'); @@ -496,8 +510,10 @@ describe('E2E: 설정 플로우', () => { test('파일 크기 제한 설정', () => { settingsTab.display(); - const fileSizeInput = containerEl.querySelector('input.file-size-input') as HTMLInputElement; - + const fileSizeInput = containerEl.querySelector( + 'input.file-size-input' + ) as HTMLInputElement; + // 유효한 값 fileSizeInput.value = '20'; fileSizeInput.dispatchEvent(new Event('input')); @@ -506,7 +522,7 @@ describe('E2E: 설정 플로우', () => { // 최대값 초과 fileSizeInput.value = '30'; fileSizeInput.dispatchEvent(new Event('input')); - + const errorMessage = containerEl.querySelector('.file-size-error'); expect(errorMessage?.textContent).toContain('최대 25MB까지 허용됩니다'); @@ -519,15 +535,17 @@ describe('E2E: 설정 플로우', () => { test('응답 형식 설정', () => { settingsTab.display(); - const formatRadios = containerEl.querySelectorAll('input[name="response-format"]') as NodeListOf; - + const formatRadios = containerEl.querySelectorAll( + 'input[name="response-format"]' + ) as NodeListOf; + // text 형식 선택 - const textRadio = Array.from(formatRadios).find(r => r.value === 'text'); + const textRadio = Array.from(formatRadios).find((r) => r.value === 'text'); textRadio?.click(); expect(plugin.settings.responseFormat).toBe('text'); // json 형식 선택 - const jsonRadio = Array.from(formatRadios).find(r => r.value === 'json'); + const jsonRadio = Array.from(formatRadios).find((r) => r.value === 'json'); jsonRadio?.click(); expect(plugin.settings.responseFormat).toBe('json'); @@ -544,13 +562,13 @@ describe('E2E: 설정 플로우', () => { apiKey: 'sk-new-api-key', language: 'ko', maxFileSize: 20, - enableNotifications: false + enableNotifications: false, }; await settingsManager.saveSettings(newSettings); expect(plugin.saveData).toHaveBeenCalledWith(newSettings); - + // 이벤트 발생 확인 const eventSpy = jest.spyOn(eventManager, 'emit'); eventManager.emit('settings:saved', newSettings); @@ -561,28 +579,30 @@ describe('E2E: 설정 플로우', () => { const savedSettings = { apiKey: 'sk-saved-key', language: 'en', - maxFileSize: 15 + maxFileSize: 15, }; plugin.loadData.mockResolvedValue(savedSettings); - + const loadedSettings = await settingsManager.loadSettings(); - + expect(loadedSettings).toMatchObject(savedSettings); }); test('설정 초기화', async () => { settingsTab.display(); - const resetButton = containerEl.querySelector('button.reset-settings') as HTMLButtonElement; - + const resetButton = containerEl.querySelector( + 'button.reset-settings' + ) as HTMLButtonElement; + // 확인 대화상자 모의 const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true); - + resetButton.click(); - + expect(confirmSpy).toHaveBeenCalledWith('모든 설정을 초기값으로 되돌리시겠습니까?'); - + // 기본 설정으로 복원 확인 expect(plugin.settings.apiKey).toBe(''); expect(plugin.settings.language).toBe('auto'); @@ -593,28 +613,32 @@ describe('E2E: 설정 플로우', () => { settingsTab.display(); // 내보내기 - const exportButton = containerEl.querySelector('button.export-settings') as HTMLButtonElement; + const exportButton = containerEl.querySelector( + 'button.export-settings' + ) as HTMLButtonElement; const downloadSpy = jest.spyOn(document, 'createElement'); - + exportButton.click(); - + // 다운로드 링크 생성 확인 expect(downloadSpy).toHaveBeenCalledWith('a'); // 가져오기 - const importInput = containerEl.querySelector('input[type="file"].import-settings') as HTMLInputElement; + const importInput = containerEl.querySelector( + 'input[type="file"].import-settings' + ) as HTMLInputElement; const importedSettings = { apiKey: 'sk-imported-key', language: 'ja', - maxFileSize: 10 + maxFileSize: 10, }; importInput.dataset.mockJson = JSON.stringify(importedSettings); importInput.dispatchEvent(new Event('change')); - + // 비동기 처리 대기 - await new Promise(resolve => setTimeout(resolve, 100)); - + await new Promise((resolve) => setTimeout(resolve, 100)); + // 설정이 가져와졌는지 확인 expect(plugin.settings.language).toBe('ja'); }); @@ -625,7 +649,7 @@ describe('E2E: 설정 플로우', () => { const oldSettings = { apiKey: 'old-key', language: 'korean', // 구버전 형식 - fileSize: 20 // 구버전 속성명 + fileSize: 20, // 구버전 속성명 }; const migratedSettings = await settingsMigrator.migrate(oldSettings); @@ -637,7 +661,7 @@ describe('E2E: 설정 플로우', () => { test('누락된 설정 자동 보완', async () => { const incompleteSettings = { - apiKey: 'test-key' + apiKey: 'test-key', // 다른 설정들이 누락됨 }; @@ -655,13 +679,15 @@ describe('E2E: 설정 플로우', () => { settingsTab.display(); const hotkeySection = containerEl.querySelector('.hotkey-settings'); - const hotkeyInput = hotkeySection?.querySelector('input.hotkey-input') as HTMLInputElement; + const hotkeyInput = hotkeySection?.querySelector( + 'input.hotkey-input' + ) as HTMLInputElement; // 단축키 입력 시뮬레이션 const keyEvent = new KeyboardEvent('keydown', { ctrlKey: true, shiftKey: true, - key: 'T' + key: 'T', }); hotkeyInput.focus(); @@ -672,11 +698,11 @@ describe('E2E: 설정 플로우', () => { // 중복 단축키 검사 const duplicateEvent = new KeyboardEvent('keydown', { ctrlKey: true, - key: 'S' // 저장 단축키와 충돌 + key: 'S', // 저장 단축키와 충돌 }); hotkeyInput.dispatchEvent(duplicateEvent); - + const warningMessage = containerEl.querySelector('.hotkey-warning'); expect(warningMessage?.textContent).toContain('이미 사용 중인 단축키입니다'); }); @@ -686,8 +712,10 @@ describe('E2E: 설정 플로우', () => { test('알림 설정 변경 시 즉시 반영', () => { settingsTab.display(); - const notificationToggle = containerEl.querySelector('input[type="checkbox"].notification-toggle') as HTMLInputElement; - + const notificationToggle = containerEl.querySelector( + 'input[type="checkbox"].notification-toggle' + ) as HTMLInputElement; + // 알림 비활성화 notificationToggle.checked = false; notificationToggle.dispatchEvent(new Event('change')); @@ -702,8 +730,10 @@ describe('E2E: 설정 플로우', () => { test('디버그 모드 토글', () => { settingsTab.display(); - const debugToggle = containerEl.querySelector('input[type="checkbox"].debug-toggle') as HTMLInputElement; - + const debugToggle = containerEl.querySelector( + 'input[type="checkbox"].debug-toggle' + ) as HTMLInputElement; + // 디버그 모드 활성화 debugToggle.checked = true; debugToggle.dispatchEvent(new Event('change')); diff --git a/tests/helpers/e2e.setup.ts b/tests/helpers/e2e.setup.ts index 7aea13b..36ecf8d 100644 --- a/tests/helpers/e2e.setup.ts +++ b/tests/helpers/e2e.setup.ts @@ -1,6 +1,6 @@ /** * E2E Test Setup - * + * * E2E 테스트 환경 설정 * - DOM 환경 초기화 * - 전역 Mock 설정 @@ -26,7 +26,7 @@ const localStorageMock = { removeItem: jest.fn(), clear: jest.fn(), length: 0, - key: jest.fn() + key: jest.fn(), }; global.localStorage = localStorageMock as any; @@ -38,8 +38,8 @@ const indexedDBMock = { open: jest.fn().mockReturnValue({ onsuccess: jest.fn(), onerror: jest.fn(), - onupgradeneeded: jest.fn() - }) + onupgradeneeded: jest.fn(), + }), }; global.indexedDB = indexedDBMock as any; @@ -59,10 +59,10 @@ global.File = class File { this.name = name; this.type = options?.type || ''; this.lastModified = options?.lastModified || Date.now(); - + // 콘텐츠 크기 계산 let totalSize = 0; - bits.forEach(bit => { + bits.forEach((bit) => { if (typeof bit === 'string') { totalSize += new TextEncoder().encode(bit).length; } else if (bit instanceof ArrayBuffer) { @@ -72,7 +72,7 @@ global.File = class File { } }); this.size = totalSize; - + // 실제 콘텐츠 저장 this.content = new ArrayBuffer(totalSize); } @@ -101,11 +101,11 @@ global.Blob = class Blob { options?: BlobPropertyBag ) { this.type = options?.type || ''; - + // 콘텐츠 크기 계산 let totalSize = 0; if (bits) { - bits.forEach(bit => { + bits.forEach((bit) => { if (typeof bit === 'string') { totalSize += new TextEncoder().encode(bit).length; } else if (bit instanceof ArrayBuffer) { @@ -195,7 +195,7 @@ global.DataTransfer = class DataTransfer { const item = { kind: 'file', type: file.type, - getAsFile: () => file + getAsFile: () => file, } as DataTransferItem; itemsArray.push(item); filesArray.push(file); @@ -249,17 +249,17 @@ global.DragEvent = class DragEvent extends Event { if (!global.AbortController) { global.AbortController = class AbortController { signal: AbortSignal; - + constructor() { this.signal = { aborted: false, onabort: null, addEventListener: jest.fn(), removeEventListener: jest.fn(), - dispatchEvent: jest.fn() + dispatchEvent: jest.fn(), } as any; } - + abort(): void { (this.signal as any).aborted = true; if ((this.signal as any).onabort) { @@ -277,26 +277,25 @@ global.performance = { clearMarks: jest.fn(), clearMeasures: jest.fn(), getEntriesByName: jest.fn(() => []), - getEntriesByType: jest.fn(() => []) + getEntriesByType: jest.fn(() => []), } as any; // IntersectionObserver Mock global.IntersectionObserver = class IntersectionObserver { - constructor( - callback: IntersectionObserverCallback, - options?: IntersectionObserverInit - ) {} - + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {} + observe(target: Element): void {} unobserve(target: Element): void {} disconnect(): void {} - takeRecords(): IntersectionObserverEntry[] { return []; } + takeRecords(): IntersectionObserverEntry[] { + return []; + } } as any; // ResizeObserver Mock global.ResizeObserver = class ResizeObserver { constructor(callback: ResizeObserverCallback) {} - + observe(target: Element, options?: ResizeObserverOptions): void {} unobserve(target: Element): void {} disconnect(): void {} @@ -305,15 +304,17 @@ global.ResizeObserver = class ResizeObserver { // MutationObserver Mock global.MutationObserver = class MutationObserver { constructor(callback: MutationCallback) {} - + observe(target: Node, options?: MutationObserverInit): void {} disconnect(): void {} - takeRecords(): MutationRecord[] { return []; } + takeRecords(): MutationRecord[] { + return []; + } } as any; // 테스트 유틸리티 함수 -export const waitFor = (ms: number): Promise => - new Promise(resolve => setTimeout(resolve, ms)); +export const waitFor = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); export const mockApiResponse = (data: any, status = 200): void => { (global.fetch as jest.Mock).mockResolvedValueOnce({ @@ -322,7 +323,7 @@ export const mockApiResponse = (data: any, status = 200): void => { statusText: status === 200 ? 'OK' : 'Error', json: () => Promise.resolve(data), text: () => Promise.resolve(JSON.stringify(data)), - headers: new Headers() + headers: new Headers(), }); }; @@ -330,20 +331,12 @@ export const mockApiError = (error: string, status = 500): void => { (global.fetch as jest.Mock).mockRejectedValueOnce(new Error(error)); }; -export const createMockFile = ( - name: string, - size: number, - type: string -): File => { +export const createMockFile = (name: string, size: number, type: string): File => { const content = new ArrayBuffer(size); return new File([content], name, { type }); }; -export const dispatchCustomEvent = ( - element: Element, - eventName: string, - detail?: any -): void => { +export const dispatchCustomEvent = (element: Element, eventName: string, detail?: any): void => { const event = new CustomEvent(eventName, { detail, bubbles: true }); element.dispatchEvent(event); }; diff --git a/tests/helpers/global.setup.ts b/tests/helpers/global.setup.ts index 6314909..2f63b51 100644 --- a/tests/helpers/global.setup.ts +++ b/tests/helpers/global.setup.ts @@ -1,6 +1,6 @@ /** * Global Setup for Tests - * + * * 모든 테스트 실행 전 한 번 실행되는 설정 * - 테스트 데이터베이스 준비 * - 환경 변수 설정 @@ -13,12 +13,12 @@ import * as path from 'path'; const globalSetup = async (): Promise => { console.log('\n🚀 Starting global test setup...\n'); - + // 1. 환경 변수 설정 process.env.NODE_ENV = 'test'; process.env.TEST_MODE = 'true'; process.env.LOG_LEVEL = 'error'; // 테스트 중 로그 최소화 - + // 2. 테스트 디렉토리 생성 const testDirs = [ '.jest-cache', @@ -26,39 +26,39 @@ const globalSetup = async (): Promise => { 'reports', 'reports/e2e', 'tests/e2e/screenshots', - 'tests/fixtures' + 'tests/fixtures', ]; - - testDirs.forEach(dir => { + + testDirs.forEach((dir) => { const fullPath = path.join(process.cwd(), dir); if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); } }); - + // 3. 테스트 설정 파일 생성 const testConfig = { apiUrl: 'http://localhost:3001/api', mockApiUrl: 'http://localhost:3002/mock', testTimeout: 30000, retryAttempts: 3, - debug: false + debug: false, }; - + fs.writeFileSync( path.join(process.cwd(), 'tests/test-config.json'), JSON.stringify(testConfig, null, 2) ); - + // 4. Mock 서버 시작 (필요한 경우) if (process.env.START_MOCK_SERVER === 'true') { console.log('Starting mock server...'); - + const mockServer = spawn('node', ['tests/mocks/server.js'], { detached: false, - stdio: 'pipe' + stdio: 'pipe', }); - + // 서버 시작 대기 await new Promise((resolve) => { mockServer.stdout?.on('data', (data) => { @@ -67,56 +67,56 @@ const globalSetup = async (): Promise => { resolve(); } }); - + // 타임아웃 설정 setTimeout(() => { console.warn('⚠️ Mock server startup timeout'); resolve(); }, 10000); }); - + // 프로세스 ID 저장 (나중에 종료용) fs.writeFileSync( path.join(process.cwd(), '.mock-server.pid'), mockServer.pid?.toString() || '' ); } - + // 5. 테스트 데이터 준비 const testData = { users: [ { id: 1, name: 'Test User 1', apiKey: 'test-key-1' }, - { id: 2, name: 'Test User 2', apiKey: 'test-key-2' } + { id: 2, name: 'Test User 2', apiKey: 'test-key-2' }, ], files: [ { id: 1, name: 'test1.mp3', size: 1024000, type: 'audio/mp3' }, - { id: 2, name: 'test2.wav', size: 2048000, type: 'audio/wav' } + { id: 2, name: 'test2.wav', size: 2048000, type: 'audio/wav' }, ], transcriptions: [ { id: 1, fileId: 1, text: 'Test transcription 1', status: 'completed' }, - { id: 2, fileId: 2, text: 'Test transcription 2', status: 'pending' } - ] + { id: 2, fileId: 2, text: 'Test transcription 2', status: 'pending' }, + ], }; - + fs.writeFileSync( path.join(process.cwd(), 'tests/fixtures/test-data.json'), JSON.stringify(testData, null, 2) ); - + // 6. 테스트용 오디오 파일 생성 createTestAudioFiles(); - + // 7. 성능 측정 시작 if (process.env.MEASURE_PERFORMANCE === 'true') { global.testStartTime = Date.now(); console.log('Performance measurement started'); } - + // 8. 브라우저 환경 준비 (E2E용) if (process.env.TEST_TYPE === 'e2e') { await setupBrowserEnvironment(); } - + console.log('✅ Global test setup completed\n'); }; @@ -125,19 +125,19 @@ const globalSetup = async (): Promise => { */ function createTestAudioFiles(): void { const audioDir = path.join(process.cwd(), 'tests/fixtures/audio'); - + if (!fs.existsSync(audioDir)) { fs.mkdirSync(audioDir, { recursive: true }); } - + // 더미 오디오 파일 생성 (실제 오디오 데이터는 아님) const files = [ - { name: 'small.mp3', size: 100 * 1024 }, // 100KB + { name: 'small.mp3', size: 100 * 1024 }, // 100KB { name: 'medium.mp3', size: 5 * 1024 * 1024 }, // 5MB - { name: 'large.mp3', size: 20 * 1024 * 1024 } // 20MB + { name: 'large.mp3', size: 20 * 1024 * 1024 }, // 20MB ]; - - files.forEach(file => { + + files.forEach((file) => { const filePath = path.join(audioDir, file.name); if (!fs.existsSync(filePath)) { // 더미 데이터로 파일 생성 @@ -153,7 +153,7 @@ function createTestAudioFiles(): void { */ async function setupBrowserEnvironment(): Promise { console.log('Setting up browser environment for E2E tests...'); - + // Playwright 설치 확인 try { require('playwright'); @@ -162,14 +162,14 @@ async function setupBrowserEnvironment(): Promise { console.error('❌ Playwright is not installed. Please run: npx playwright install'); process.exit(1); } - + // 브라우저 다운로드 상태 확인 const { chromium, firefox, webkit } = require('playwright'); - + try { - await chromium.launch({ headless: true }).then(b => b.close()); - await firefox.launch({ headless: true }).then(b => b.close()); - await webkit.launch({ headless: true }).then(b => b.close()); + await chromium.launch({ headless: true }).then((b) => b.close()); + await firefox.launch({ headless: true }).then((b) => b.close()); + await webkit.launch({ headless: true }).then((b) => b.close()); console.log('✅ All browsers are ready'); } catch (error) { console.error('❌ Some browsers are not installed. Please run: npx playwright install'); @@ -180,15 +180,10 @@ async function setupBrowserEnvironment(): Promise { * 환경 변수 검증 */ function validateEnvironment(): void { - const requiredEnvVars = [ - 'NODE_ENV', - 'TEST_MODE' - ]; - - const missingVars = requiredEnvVars.filter( - varName => !process.env[varName] - ); - + const requiredEnvVars = ['NODE_ENV', 'TEST_MODE']; + + const missingVars = requiredEnvVars.filter((varName) => !process.env[varName]); + if (missingVars.length > 0) { console.error(`Missing required environment variables: ${missingVars.join(', ')}`); process.exit(1); @@ -201,4 +196,4 @@ process.on('unhandledRejection', (reason, promise) => { process.exit(1); }); -export default globalSetup; \ No newline at end of file +export default globalSetup; diff --git a/tests/helpers/global.teardown.ts b/tests/helpers/global.teardown.ts index 3ca2d83..d9cf15f 100644 --- a/tests/helpers/global.teardown.ts +++ b/tests/helpers/global.teardown.ts @@ -1,6 +1,6 @@ /** * Global Teardown for Tests - * + * * 모든 테스트 완료 후 한 번 실행되는 정리 작업 * - 테스트 서버 종료 * - 임시 파일 정리 @@ -16,7 +16,7 @@ const execAsync = promisify(exec); const globalTeardown = async (): Promise => { console.log('\n🧹 Starting global test teardown...\n'); - + try { // 1. Mock 서버 종료 if (fs.existsSync('.mock-server.pid')) { @@ -31,39 +31,35 @@ const globalTeardown = async (): Promise => { fs.unlinkSync('.mock-server.pid'); } } - + // 2. 성능 측정 결과 출력 if (global.testStartTime) { const duration = Date.now() - global.testStartTime; const minutes = Math.floor(duration / 60000); const seconds = ((duration % 60000) / 1000).toFixed(2); console.log(`\n⏱️ Total test duration: ${minutes}m ${seconds}s\n`); - + // 성능 리포트 생성 const performanceReport = { totalDuration: duration, timestamp: new Date().toISOString(), environment: process.env.NODE_ENV, - nodeVersion: process.version + nodeVersion: process.version, }; - + fs.writeFileSync( path.join(process.cwd(), 'reports/performance.json'), JSON.stringify(performanceReport, null, 2) ); } - + // 3. 임시 파일 정리 (선택적) if (process.env.CLEANUP_TEMP_FILES === 'true') { console.log('Cleaning up temporary files...'); - - const tempDirs = [ - 'tests/fixtures/temp', - 'tests/e2e/downloads', - '.tmp' - ]; - - tempDirs.forEach(dir => { + + const tempDirs = ['tests/fixtures/temp', 'tests/e2e/downloads', '.tmp']; + + tempDirs.forEach((dir) => { const fullPath = path.join(process.cwd(), dir); if (fs.existsSync(fullPath)) { fs.rmSync(fullPath, { recursive: true, force: true }); @@ -71,27 +67,26 @@ const globalTeardown = async (): Promise => { } }); } - + // 4. 테스트 결과 집계 await aggregateTestResults(); - + // 5. 커버리지 리포트 병합 if (fs.existsSync('coverage')) { await mergeCoverageReports(); } - + // 6. 테스트 실패 시 로그 수집 if (process.env.COLLECT_FAILURE_LOGS === 'true') { await collectFailureLogs(); } - + // 7. 알림 발송 (CI 환경에서) if (process.env.CI === 'true' && process.env.SEND_NOTIFICATIONS === 'true') { await sendTestNotifications(); } - + console.log('✅ Global test teardown completed\n'); - } catch (error) { console.error('❌ Error during teardown:', error); process.exit(1); @@ -103,30 +98,32 @@ const globalTeardown = async (): Promise => { */ async function aggregateTestResults(): Promise { console.log('Aggregating test results...'); - + const resultsDir = path.join(process.cwd(), 'reports'); const results: any[] = []; - + // 모든 결과 파일 읽기 if (fs.existsSync(resultsDir)) { - const files = fs.readdirSync(resultsDir).filter(f => f.endsWith('.xml')); - - files.forEach(file => { + const files = fs.readdirSync(resultsDir).filter((f) => f.endsWith('.xml')); + + files.forEach((file) => { const content = fs.readFileSync(path.join(resultsDir, file), 'utf8'); // XML 파싱 (간단한 예시) - const matches = content.match(/]*tests="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"[^>]*time="([\d.]+)"/); + const matches = content.match( + /]*tests="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"[^>]*time="([\d.]+)"/ + ); if (matches) { results.push({ file, tests: parseInt(matches[1]), failures: parseInt(matches[2]), errors: parseInt(matches[3]), - time: parseFloat(matches[4]) + time: parseFloat(matches[4]), }); } }); } - + // 집계 결과 const summary = { totalTests: results.reduce((sum, r) => sum + r.tests, 0), @@ -134,9 +131,9 @@ async function aggregateTestResults(): Promise { totalErrors: results.reduce((sum, r) => sum + r.errors, 0), totalTime: results.reduce((sum, r) => sum + r.time, 0), testSuites: results.length, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; - + // 요약 출력 console.log('\n📊 Test Summary:'); console.log(` Total Tests: ${summary.totalTests}`); @@ -144,13 +141,15 @@ async function aggregateTestResults(): Promise { console.log(` Failed: ${summary.totalFailures}`); console.log(` Errors: ${summary.totalErrors}`); console.log(` Duration: ${summary.totalTime.toFixed(2)}s`); - console.log(` Success Rate: ${((1 - (summary.totalFailures + summary.totalErrors) / summary.totalTests) * 100).toFixed(2)}%\n`); - - // 요약 파일 저장 - fs.writeFileSync( - path.join(resultsDir, 'summary.json'), - JSON.stringify(summary, null, 2) + console.log( + ` Success Rate: ${( + (1 - (summary.totalFailures + summary.totalErrors) / summary.totalTests) * + 100 + ).toFixed(2)}%\n` ); + + // 요약 파일 저장 + fs.writeFileSync(path.join(resultsDir, 'summary.json'), JSON.stringify(summary, null, 2)); } /** @@ -158,18 +157,20 @@ async function aggregateTestResults(): Promise { */ async function mergeCoverageReports(): Promise { console.log('Merging coverage reports...'); - + try { // nyc를 사용한 커버리지 병합 await execAsync('npx nyc merge coverage coverage/merged'); - await execAsync('npx nyc report --reporter=lcov --reporter=text-summary --report-dir=coverage/final'); - + await execAsync( + 'npx nyc report --reporter=lcov --reporter=text-summary --report-dir=coverage/final' + ); + // 커버리지 요약 읽기 const summaryPath = path.join(process.cwd(), 'coverage/final/coverage-summary.json'); if (fs.existsSync(summaryPath)) { const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); const total = summary.total; - + console.log('\n📈 Coverage Summary:'); console.log(` Lines: ${total.lines.pct}%`); console.log(` Statements: ${total.statements.pct}%`); @@ -186,23 +187,20 @@ async function mergeCoverageReports(): Promise { */ async function collectFailureLogs(): Promise { console.log('Collecting failure logs...'); - + const logsDir = path.join(process.cwd(), 'reports/failure-logs'); if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } - + // Jest 로그 파일 확인 const jestLogPath = path.join(process.cwd(), 'jest.log'); if (fs.existsSync(jestLogPath)) { const content = fs.readFileSync(jestLogPath, 'utf8'); const failures = content.match(/FAIL.*?(?=PASS|FAIL|$)/gs); - + if (failures && failures.length > 0) { - fs.writeFileSync( - path.join(logsDir, 'jest-failures.log'), - failures.join('\n') - ); + fs.writeFileSync(path.join(logsDir, 'jest-failures.log'), failures.join('\n')); console.log(`Collected ${failures.length} test failures`); } } @@ -213,35 +211,55 @@ async function collectFailureLogs(): Promise { */ async function sendTestNotifications(): Promise { console.log('Sending test notifications...'); - + const summaryPath = path.join(process.cwd(), 'reports/summary.json'); if (!fs.existsSync(summaryPath)) { return; } - + const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); - const passed = summary.totalTests === summary.totalTests - summary.totalFailures - summary.totalErrors; - + const passed = + summary.totalTests === summary.totalTests - summary.totalFailures - summary.totalErrors; + // Slack 알림 예시 if (process.env.SLACK_WEBHOOK) { const message = { text: `Test Results: ${passed ? '✅ All tests passed!' : '❌ Some tests failed'}`, - attachments: [{ - color: passed ? 'good' : 'danger', - fields: [ - { title: 'Total Tests', value: summary.totalTests, short: true }, - { title: 'Failed', value: summary.totalFailures + summary.totalErrors, short: true }, - { title: 'Duration', value: `${summary.totalTime.toFixed(2)}s`, short: true }, - { title: 'Success Rate', value: `${((1 - (summary.totalFailures + summary.totalErrors) / summary.totalTests) * 100).toFixed(2)}%`, short: true } - ] - }] + attachments: [ + { + color: passed ? 'good' : 'danger', + fields: [ + { title: 'Total Tests', value: summary.totalTests, short: true }, + { + title: 'Failed', + value: summary.totalFailures + summary.totalErrors, + short: true, + }, + { + title: 'Duration', + value: `${summary.totalTime.toFixed(2)}s`, + short: true, + }, + { + title: 'Success Rate', + value: `${( + (1 - + (summary.totalFailures + summary.totalErrors) / + summary.totalTests) * + 100 + ).toFixed(2)}%`, + short: true, + }, + ], + }, + ], }; - + try { await fetch(process.env.SLACK_WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(message) + body: JSON.stringify(message), }); console.log('✅ Slack notification sent'); } catch (error) { @@ -259,4 +277,4 @@ process.on('exit', (code) => { } }); -export default globalTeardown; \ No newline at end of file +export default globalTeardown; diff --git a/tests/helpers/mockDataFactory.ts b/tests/helpers/mockDataFactory.ts index 9b33471..85cca62 100644 --- a/tests/helpers/mockDataFactory.ts +++ b/tests/helpers/mockDataFactory.ts @@ -11,28 +11,30 @@ import type { ValidationResult, ProcessedAudio, AudioMetadata, - FormatOptions + FormatOptions, } from '../../src/types'; import type { SpeechToTextSettings } from '../../src/domain/models/Settings'; /** * Mock 오디오 파일 생성 */ -export function createMockAudioFile(options: { - name?: string; - path?: string; - extension?: string; - size?: number; - mtime?: number; - ctime?: number; -} = {}): TFile { +export function createMockAudioFile( + options: { + name?: string; + path?: string; + extension?: string; + size?: number; + mtime?: number; + ctime?: number; + } = {} +): TFile { const defaults = { name: 'test-audio.mp3', path: 'test-audio.mp3', extension: 'mp3', size: 1024 * 1024, // 1MB mtime: Date.now(), - ctime: Date.now() + ctime: Date.now(), }; const config = { ...defaults, ...options }; @@ -42,7 +44,7 @@ export function createMockAudioFile(options: { const stat: FileStats = { size: config.size, mtime: config.mtime, - ctime: config.ctime + ctime: config.ctime, }; const vaultStub = Object.create(Object.prototype); @@ -53,7 +55,7 @@ export function createMockAudioFile(options: { extension: config.extension, stat, vault: vaultStub, - basename: config.name.replace(/\.[^/.]+$/, '') + basename: config.name.replace(/\.[^/.]+$/, ''), }); return file; @@ -65,28 +67,30 @@ export function createMockAudioFile(options: { export function createMockArrayBuffer(size: number = 1024): ArrayBuffer { const buffer = new ArrayBuffer(size); const view = new Uint8Array(buffer); - + // 간단한 패턴으로 채우기 for (let i = 0; i < size; i++) { view[i] = i % 256; } - + return buffer; } /** * Mock Whisper API 응답 생성 */ -export function createMockWhisperResponse(options: { - text?: string; - language?: string; - duration?: number; - segments?: Array<{ start: number; end: number; text: string }>; -} = {}): WhisperResponse { +export function createMockWhisperResponse( + options: { + text?: string; + language?: string; + duration?: number; + segments?: Array<{ start: number; end: number; text: string }>; + } = {} +): WhisperResponse { const defaults = { text: '안녕하세요. 테스트 변환 텍스트입니다.', language: 'ko', - duration: 5.5 + duration: 5.5, }; const config = { ...defaults, ...options }; @@ -94,7 +98,7 @@ export function createMockWhisperResponse(options: { const response: WhisperResponse = { text: config.text, language: config.language, - duration: config.duration + duration: config.duration, }; if (config.segments) { @@ -107,14 +111,16 @@ export function createMockWhisperResponse(options: { /** * Mock TranscriptionResult 생성 */ -export function createMockTranscriptionResult(options: { - text?: string; - language?: string; - segments?: TranscriptionSegment[]; -} = {}): TranscriptionResult { +export function createMockTranscriptionResult( + options: { + text?: string; + language?: string; + segments?: TranscriptionSegment[]; + } = {} +): TranscriptionResult { const defaults = { text: '변환된 텍스트입니다.', - language: 'ko' + language: 'ko', }; const config = { ...defaults, ...options }; @@ -122,14 +128,16 @@ export function createMockTranscriptionResult(options: { return { text: config.text, language: config.language, - segments: config.segments + segments: config.segments, }; } /** * Mock Settings 생성 */ -export function createMockSettings(overrides: Partial = {}): SpeechToTextSettings { +export function createMockSettings( + overrides: Partial = {} +): SpeechToTextSettings { return { apiKey: 'test-api-key', apiEndpoint: 'https://api.openai.com/v1/audio/transcriptions', @@ -147,14 +155,14 @@ export function createMockSettings(overrides: Partial = {} shortcuts: { startRecording: 'Ctrl+Shift+R', stopRecording: 'Ctrl+Shift+S', - insertTranscription: 'Ctrl+Shift+I' + insertTranscription: 'Ctrl+Shift+I', }, ui: { showNotifications: true, notificationDuration: 5000, confirmBeforeInsert: false, showProgressBar: true, - theme: 'auto' + theme: 'auto', }, advanced: { enableDebugMode: false, @@ -162,22 +170,24 @@ export function createMockSettings(overrides: Partial = {} retryDelay: 1000, timeout: 30000, enableCache: true, - cacheExpiration: 3600000 + cacheExpiration: 3600000, }, - ...overrides + ...overrides, }; } /** * Mock ValidationResult 생성 */ -export function createMockValidationResult(options: { - valid?: boolean; - errors?: string[]; - warnings?: string[]; -} = {}): ValidationResult { +export function createMockValidationResult( + options: { + valid?: boolean; + errors?: string[]; + warnings?: string[]; + } = {} +): ValidationResult { const defaults = { - valid: true + valid: true, }; const config = { ...defaults, ...options }; @@ -185,24 +195,26 @@ export function createMockValidationResult(options: { return { valid: config.valid, errors: config.errors, - warnings: config.warnings + warnings: config.warnings, }; } /** * Mock ProcessedAudio 생성 */ -export function createMockProcessedAudio(options: { - buffer?: ArrayBuffer; - metadata?: Partial; - originalFile?: TFile; - compressed?: boolean; -} = {}): ProcessedAudio { +export function createMockProcessedAudio( + options: { + buffer?: ArrayBuffer; + metadata?: Partial; + originalFile?: TFile; + compressed?: boolean; + } = {} +): ProcessedAudio { const defaults = { buffer: createMockArrayBuffer(1024), metadata: createMockAudioMetadata(), originalFile: createMockAudioFile(), - compressed: false + compressed: false, }; const config = { ...defaults, ...options }; @@ -211,7 +223,7 @@ export function createMockProcessedAudio(options: { buffer: config.buffer, metadata: { ...defaults.metadata, ...config.metadata }, originalFile: config.originalFile, - compressed: config.compressed + compressed: config.compressed, }; } @@ -225,7 +237,7 @@ export function createMockAudioMetadata(overrides: Partial = {}): sampleRate: 44100, channels: 2, codec: 'mp3', - ...overrides + ...overrides, }; } @@ -238,7 +250,7 @@ export function createMockFormatOptions(overrides: Partial = {}): timestampFormat: 'inline', cleanupText: true, paragraphBreaks: true, - ...overrides + ...overrides, }; } @@ -263,52 +275,56 @@ export function createMockAPIErrorResponse(status: number, message: string) { error: { message, type: 'error', - code: `error_${status}` - } + code: `error_${status}`, + }, }, - headers: {} + headers: {}, }; } /** * Mock File 객체 생성 (브라우저 File API) */ -export function createMockFile(options: { - name?: string; - size?: number; - type?: string; - lastModified?: number; -} = {}): File { +export function createMockFile( + options: { + name?: string; + size?: number; + type?: string; + lastModified?: number; + } = {} +): File { const defaults = { name: 'test.mp3', size: 1024 * 1024, type: 'audio/mp3', - lastModified: Date.now() + lastModified: Date.now(), }; const config = { ...defaults, ...options }; const buffer = new ArrayBuffer(config.size); - + return new File([buffer], config.name, { type: config.type, - lastModified: config.lastModified + lastModified: config.lastModified, }); } /** * Mock TranscriptionSegment 생성 */ -export function createMockTranscriptionSegment(options: { - id?: number; - start?: number; - end?: number; - text?: string; -} = {}): TranscriptionSegment { +export function createMockTranscriptionSegment( + options: { + id?: number; + start?: number; + end?: number; + text?: string; + } = {} +): TranscriptionSegment { const defaults = { id: 0, start: 0, end: 5, - text: '테스트 세그먼트 텍스트' + text: '테스트 세그먼트 텍스트', }; const config = { ...defaults, ...options }; @@ -317,7 +333,7 @@ export function createMockTranscriptionSegment(options: { id: config.id, start: config.start, end: config.end, - text: config.text + text: config.text, }; } @@ -334,7 +350,7 @@ export function createMockSegments(count: number = 3): TranscriptionSegment[] { id: i, start: currentTime, end: currentTime + duration, - text: `세그먼트 ${i + 1} 텍스트입니다.` + text: `세그먼트 ${i + 1} 텍스트입니다.`, }); currentTime += duration; } @@ -352,7 +368,7 @@ export function createMockVault(): Partial { modify: jest.fn().mockResolvedValue(undefined), create: jest.fn().mockResolvedValue(createMockAudioFile()), delete: jest.fn().mockResolvedValue(undefined), - rename: jest.fn().mockResolvedValue(undefined) + rename: jest.fn().mockResolvedValue(undefined), }; } @@ -392,8 +408,8 @@ export function createMockWAVBuffer(sampleRate: number = 44100, duration: number let offset = 44; const frequency = 440; // A4 for (let i = 0; i < numSamples; i++) { - const sample = Math.sin(2 * Math.PI * frequency * i / sampleRate); - const value = sample * 0x7FFF; + const sample = Math.sin((2 * Math.PI * frequency * i) / sampleRate); + const value = sample * 0x7fff; view.setInt16(offset, value, true); offset += 2; } diff --git a/tests/helpers/testSequencer.js b/tests/helpers/testSequencer.js index 1de9cfa..98899fb 100644 --- a/tests/helpers/testSequencer.js +++ b/tests/helpers/testSequencer.js @@ -1,6 +1,6 @@ /** * Custom Test Sequencer - * + * * 테스트 실행 순서 최적화 * - 실패한 테스트 우선 실행 * - 빠른 테스트 먼저 실행 @@ -16,50 +16,50 @@ class CustomSequencer extends Sequencer { sort(tests) { // 테스트 결과 캐시 로드 const cache = this.getCache(tests); - + return tests.sort((testA, testB) => { // 1. 실패한 테스트 우선 const aFailed = cache[testA.path]?.failed || false; const bFailed = cache[testB.path]?.failed || false; - + if (aFailed && !bFailed) return -1; if (!aFailed && bFailed) return 1; - + // 2. 테스트 타입별 우선순위 const priority = { - 'unit': 1, - 'integration': 2, - 'e2e': 3 + unit: 1, + integration: 2, + e2e: 3, }; - + const aType = this.getTestType(testA.path); const bType = this.getTestType(testB.path); - + if (priority[aType] !== priority[bType]) { return priority[aType] - priority[bType]; } - + // 3. 실행 시간이 짧은 테스트 우선 const aDuration = cache[testA.path]?.duration || 0; const bDuration = cache[testB.path]?.duration || 0; - + if (aDuration !== bDuration) { return aDuration - bDuration; } - + // 4. 파일 크기가 작은 테스트 우선 const aSize = testA.context.config.testEnvironmentOptions?.fileSize || 0; const bSize = testB.context.config.testEnvironmentOptions?.fileSize || 0; - + if (aSize !== bSize) { return aSize - bSize; } - + // 5. 알파벳 순서 return testA.path.localeCompare(testB.path); }); } - + /** * 테스트 타입 판별 */ @@ -69,37 +69,37 @@ class CustomSequencer extends Sequencer { if (path.includes('/e2e/')) return 'e2e'; return 'unit'; } - + /** * 테스트 결과 캐시 저장 */ cacheResults(tests, results) { const cache = {}; - + tests.forEach((test, index) => { const result = results[index]; cache[test.path] = { failed: result.numFailingTests > 0, duration: result.perfStats.runtime, - lastRun: Date.now() + lastRun: Date.now(), }; }); - + // 캐시 파일에 저장 const fs = require('fs'); const path = require('path'); const cacheDir = path.join(process.cwd(), '.jest-cache'); - + if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }); } - + fs.writeFileSync( path.join(cacheDir, 'test-sequencer-cache.json'), JSON.stringify(cache, null, 2) ); } - + /** * 캐시 로드 */ @@ -107,7 +107,7 @@ class CustomSequencer extends Sequencer { const fs = require('fs'); const path = require('path'); const cacheFile = path.join(process.cwd(), '.jest-cache', 'test-sequencer-cache.json'); - + if (fs.existsSync(cacheFile)) { try { return JSON.parse(fs.readFileSync(cacheFile, 'utf8')); @@ -115,10 +115,10 @@ class CustomSequencer extends Sequencer { console.warn('Failed to load test sequencer cache:', error); } } - + return {}; } - + /** * 샤드별 테스트 분배 (병렬 실행용) */ @@ -127,9 +127,9 @@ class CustomSequencer extends Sequencer { const shardSize = Math.ceil(tests.length / shardCount); const start = shardSize * shardIndex; const end = Math.min(start + shardSize, tests.length); - + return tests.slice(start, end); } } -module.exports = CustomSequencer; \ No newline at end of file +module.exports = CustomSequencer; diff --git a/tests/helpers/testSetup.js b/tests/helpers/testSetup.js index ae21ef7..b88f625 100644 --- a/tests/helpers/testSetup.js +++ b/tests/helpers/testSetup.js @@ -40,19 +40,19 @@ global.AudioContext = jest.fn().mockImplementation(() => ({ sampleRate: 44100, numberOfChannels: 2, length: 441000, - getChannelData: jest.fn().mockReturnValue(new Float32Array(441000)) + getChannelData: jest.fn().mockReturnValue(new Float32Array(441000)), }), createBufferSource: jest.fn().mockReturnValue({ buffer: null, connect: jest.fn(), start: jest.fn(), - stop: jest.fn() + stop: jest.fn(), }), createChannelMerger: jest.fn().mockReturnValue({ - connect: jest.fn() + connect: jest.fn(), }), destination: {}, - close: jest.fn() + close: jest.fn(), })); // OfflineAudioContext 모킹 @@ -63,10 +63,10 @@ global.OfflineAudioContext = jest.fn().mockImplementation((channels, length, sam createBufferSource: jest.fn().mockReturnValue({ buffer: null, connect: jest.fn(), - start: jest.fn() + start: jest.fn(), }), createChannelMerger: jest.fn().mockReturnValue({ - connect: jest.fn() + connect: jest.fn(), }), destination: {}, startRendering: jest.fn().mockResolvedValue({ @@ -74,8 +74,8 @@ global.OfflineAudioContext = jest.fn().mockImplementation((channels, length, sam sampleRate, numberOfChannels: channels, length, - getChannelData: jest.fn().mockReturnValue(new Float32Array(length)) - }) + getChannelData: jest.fn().mockReturnValue(new Float32Array(length)), + }), })); // FormData 모킹 @@ -89,7 +89,7 @@ global.FormData = jest.fn().mockImplementation(() => ({ entries: jest.fn(), keys: jest.fn(), values: jest.fn(), - forEach: jest.fn() + forEach: jest.fn(), })); // Blob 모킹 @@ -106,7 +106,7 @@ global.Blob = jest.fn().mockImplementation(function (parts, options) { slice: jest.fn(), stream: jest.fn(), text: jest.fn(), - arrayBuffer: jest.fn() + arrayBuffer: jest.fn(), }; }); @@ -116,7 +116,7 @@ global.File = jest.fn().mockImplementation(function (parts, name, options) { ...new Blob(parts, options), name, lastModified: options?.lastModified || Date.now(), - webkitRelativePath: '' + webkitRelativePath: '', }; }); @@ -126,11 +126,11 @@ global.DataTransfer = jest.fn().mockImplementation(function () { const items = { add: (file) => { files.push(file); - } + }, }; return { files, - items + items, }; }); @@ -148,48 +148,49 @@ global.AbortController = jest.fn().mockImplementation(() => ({ aborted: false, onabort: null, addEventListener: jest.fn(), - removeEventListener: jest.fn() + removeEventListener: jest.fn(), }, abort: jest.fn(function () { this.signal.aborted = true; if (this.signal.onabort) this.signal.onabort(); - }) + }), })); // 커스텀 매처 추가 expect.extend({ toBeValidArrayBuffer(received) { const pass = received instanceof ArrayBuffer && received.byteLength > 0; - + if (pass) { return { message: () => `expected ${received} not to be a valid ArrayBuffer`, - pass: true + pass: true, }; } else { return { message: () => `expected ${received} to be a valid ArrayBuffer with byteLength > 0`, - pass: false + pass: false, }; } }, - + toContainTimestamp(received) { const timestampRegex = /\[\d{2}:\d{2}(:\d{2})?\]/; const pass = timestampRegex.test(received); - + if (pass) { return { message: () => `expected "${received}" not to contain timestamp`, - pass: true + pass: true, }; } else { return { - message: () => `expected "${received}" to contain timestamp in format [MM:SS] or [HH:MM:SS]`, - pass: false + message: () => + `expected "${received}" to contain timestamp in format [MM:SS] or [HH:MM:SS]`, + pass: false, }; } - } + }, }); // 테스트 타임아웃 설정 @@ -203,7 +204,11 @@ if (typeof jest !== 'undefined' && typeof jest.advanceTimersByTime === 'function try { return originalAdvanceTimersByTime(...args); } catch (error) { - if (error && typeof error.message === 'string' && error.message.includes('not replaced with fake timers')) { + if ( + error && + typeof error.message === 'string' && + error.message.includes('not replaced with fake timers') + ) { // continue to update mock time for compatibility } else { throw error; diff --git a/tests/integration/WhisperAPI.test.ts b/tests/integration/WhisperAPI.test.ts index eca1486..717d392 100644 --- a/tests/integration/WhisperAPI.test.ts +++ b/tests/integration/WhisperAPI.test.ts @@ -3,7 +3,12 @@ * OpenAI Whisper API와의 통합을 테스트합니다. */ -import { WhisperService, WhisperAPIError, AuthenticationError, RateLimitError } from '../../src/infrastructure/api/WhisperService'; +import { + WhisperService, + WhisperAPIError, + AuthenticationError, + RateLimitError, +} from '../../src/infrastructure/api/WhisperService'; import { SettingsManager, PluginSettings } from '../../src/infrastructure/storage/SettingsManager'; import type { ILogger, WhisperOptions, WhisperResponse } from '../../src/types'; import { requestUrl } from 'obsidian'; @@ -13,8 +18,8 @@ jest.mock('obsidian', () => ({ requestUrl: jest.fn(), Plugin: jest.fn().mockImplementation(() => ({ loadData: jest.fn(), - saveData: jest.fn() - })) + saveData: jest.fn(), + })), })); // Mock Logger @@ -43,12 +48,12 @@ describe('WhisperService Integration Tests', () => { const expectedResponse = { text: '안녕하세요, 테스트입니다.', language: 'ko', - duration: 2.5 + duration: 2.5, }; (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: expectedResponse + json: expectedResponse, }); // Act @@ -59,7 +64,7 @@ describe('WhisperService Integration Tests', () => { expect(logger.debug).toHaveBeenCalledWith( expect.stringContaining('Starting transcription request'), expect.objectContaining({ - fileSize: 1000 + fileSize: 1000, }) ); expect(logger.info).toHaveBeenCalledWith( @@ -86,7 +91,7 @@ describe('WhisperService Integration Tests', () => { temperature: 0.0, avg_logprob: -0.5, compression_ratio: 1.2, - no_speech_prob: 0.01 + no_speech_prob: 0.01, }, { id: 1, @@ -98,19 +103,19 @@ describe('WhisperService Integration Tests', () => { temperature: 0.0, avg_logprob: -0.3, compression_ratio: 1.1, - no_speech_prob: 0.02 - } - ] + no_speech_prob: 0.02, + }, + ], }; (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: verboseResponse + json: verboseResponse, }); // Act const result = await whisperService.transcribe(audioBuffer, { - responseFormat: 'verbose_json' + responseFormat: 'verbose_json', }); // Assert @@ -126,12 +131,12 @@ describe('WhisperService Integration Tests', () => { (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: textResponse // text 형식은 string으로 반환 + json: textResponse, // text 형식은 string으로 반환 }); // Act const result = await whisperService.transcribe(audioBuffer, { - responseFormat: 'text' + responseFormat: 'text', }); // Assert @@ -144,17 +149,17 @@ describe('WhisperService Integration Tests', () => { it('401 에러를 AuthenticationError로 변환해야 함', async () => { // Arrange const audioBuffer = new ArrayBuffer(1000); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 401, - json: { error: { message: 'Invalid API key' } } + json: { error: { message: 'Invalid API key' } }, }); // Act & Assert - await expect(whisperService.transcribe(audioBuffer)) - .rejects - .toThrow(AuthenticationError); - + await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow( + AuthenticationError + ); + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('API Error: 401'), undefined, @@ -165,17 +170,15 @@ describe('WhisperService Integration Tests', () => { it('429 에러를 RateLimitError로 변환해야 함', async () => { // Arrange const audioBuffer = new ArrayBuffer(1000); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 429, json: { error: { message: 'Rate limit exceeded' } }, - headers: { 'retry-after': '60' } + headers: { 'retry-after': '60' }, }); // Act & Assert - await expect(whisperService.transcribe(audioBuffer)) - .rejects - .toThrow(RateLimitError); + await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow(RateLimitError); }); it('파일 크기 제한을 검증해야 함', async () => { @@ -183,9 +186,9 @@ describe('WhisperService Integration Tests', () => { const largeBuffer = new ArrayBuffer(26 * 1024 * 1024); // 26MB // Act & Assert - await expect(whisperService.transcribe(largeBuffer)) - .rejects - .toThrow('File size exceeds API limit (25MB)'); + await expect(whisperService.transcribe(largeBuffer)).rejects.toThrow( + 'File size exceeds API limit (25MB)' + ); }); it('네트워크 에러를 재시도해야 함', async () => { @@ -200,7 +203,7 @@ describe('WhisperService Integration Tests', () => { } return Promise.resolve({ status: 200, - json: { text: 'Success after retry' } + json: { text: 'Success after retry' }, }); }); @@ -210,9 +213,7 @@ describe('WhisperService Integration Tests', () => { // Assert expect(result.text).toBe('Success after retry'); expect(attempts).toBe(3); - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining('Retrying after') - ); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Retrying after')); }); it('최대 재시도 횟수 초과 시 에러를 발생시켜야 함', async () => { @@ -222,9 +223,9 @@ describe('WhisperService Integration Tests', () => { (requestUrl as jest.Mock).mockRejectedValue(new Error('Network error')); // Act & Assert - await expect(whisperService.transcribe(audioBuffer)) - .rejects - .toThrow('Operation failed after 3 attempts'); + await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow( + 'Operation failed after 3 attempts' + ); }); }); @@ -233,7 +234,7 @@ describe('WhisperService Integration Tests', () => { // Arrange (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: { text: 'Test' } + json: { text: 'Test' }, }); // Act @@ -247,7 +248,7 @@ describe('WhisperService Integration Tests', () => { // Arrange (requestUrl as jest.Mock).mockResolvedValue({ status: 401, - json: { error: { message: 'Invalid API key' } } + json: { error: { message: 'Invalid API key' } }, }); // Act @@ -255,9 +256,7 @@ describe('WhisperService Integration Tests', () => { // Assert expect(isValid).toBe(false); - expect(logger.warn).toHaveBeenCalledWith( - 'API key validation failed: Invalid key' - ); + expect(logger.warn).toHaveBeenCalledWith('API key validation failed: Invalid key'); }); }); @@ -265,7 +264,7 @@ describe('WhisperService Integration Tests', () => { it('진행 중인 요청을 취소할 수 있어야 함', async () => { // Arrange const audioBuffer = new ArrayBuffer(1000); - + (requestUrl as jest.Mock).mockImplementation(() => { // AbortError 시뮬레이션 const error = new Error('Aborted'); @@ -291,7 +290,7 @@ describe('WhisperService Integration Tests', () => { language: 'ko', prompt: '이것은 한국어 음성입니다.', temperature: 0.7, - responseFormat: 'verbose_json' + responseFormat: 'verbose_json', }; let capturedFormData: FormData | null = null; @@ -299,7 +298,7 @@ describe('WhisperService Integration Tests', () => { capturedFormData = params.body; return Promise.resolve({ status: 200, - json: { text: 'Test' } + json: { text: 'Test' }, }); }); @@ -313,8 +312,8 @@ describe('WhisperService Integration Tests', () => { expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ - Authorization: `Bearer ${mockApiKey}` - }) + Authorization: `Bearer ${mockApiKey}`, + }), }) ); }); @@ -326,12 +325,12 @@ describe('WhisperService Integration Tests', () => { (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: { text: 'Test' } + json: { text: 'Test' }, }); // Act await whisperService.transcribe(audioBuffer, { - prompt: longPrompt + prompt: longPrompt, }); // Assert @@ -346,22 +345,22 @@ describe('WhisperService Integration Tests', () => { const audioBuffer = new ArrayBuffer(1000); (requestUrl as jest.Mock).mockResolvedValue({ status: 500, - json: { error: { message: 'Server error' } } + json: { error: { message: 'Server error' } }, }); // Act & Assert // 5번 실패 시도 for (let i = 0; i < 5; i++) { - await expect(whisperService.transcribe(audioBuffer)) - .rejects - .toThrow(WhisperAPIError); + await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow( + WhisperAPIError + ); } // Circuit이 열린 후 요청 시도 - await expect(whisperService.transcribe(audioBuffer)) - .rejects - .toThrow('Circuit breaker is open'); - + await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow( + 'Circuit breaker is open' + ); + expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('Circuit breaker opened') ); @@ -370,17 +369,15 @@ describe('WhisperService Integration Tests', () => { it('Circuit을 수동으로 리셋할 수 있어야 함', async () => { // Arrange const audioBuffer = new ArrayBuffer(1000); - + // Circuit을 열기 위해 실패 유도 (requestUrl as jest.Mock).mockResolvedValue({ status: 500, - json: { error: { message: 'Server error' } } + json: { error: { message: 'Server error' } }, }); for (let i = 0; i < 5; i++) { - await expect(whisperService.transcribe(audioBuffer)) - .rejects - .toThrow(); + await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow(); } // Circuit 리셋 @@ -389,7 +386,7 @@ describe('WhisperService Integration Tests', () => { // 성공 응답 설정 (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: { text: 'Success after reset' } + json: { text: 'Success after reset' }, }); // Act @@ -411,7 +408,7 @@ describe('SettingsManager Integration Tests', () => { logger = new MockLogger(); mockPlugin = { loadData: jest.fn(), - saveData: jest.fn() + saveData: jest.fn(), }; settingsManager = new SettingsManager(mockPlugin, logger); }); @@ -428,12 +425,12 @@ describe('SettingsManager Integration Tests', () => { expect(result).toBe(true); expect(mockPlugin.saveData).toHaveBeenCalledWith( expect.objectContaining({ - encryptedApiKey: expect.any(String) + encryptedApiKey: expect.any(String), }) ); expect(mockPlugin.saveData).toHaveBeenCalledWith( expect.not.objectContaining({ - apiKey: apiKey + apiKey: apiKey, }) ); }); @@ -478,7 +475,7 @@ describe('SettingsManager Integration Tests', () => { enableCache: false, cacheTTL: 7200000, enableDebugLogging: true, - responseFormat: 'verbose_json' + responseFormat: 'verbose_json', }; mockPlugin.loadData.mockResolvedValue(settings); @@ -490,7 +487,7 @@ describe('SettingsManager Integration Tests', () => { expect(loaded).toMatchObject({ language: 'ko', autoInsert: false, - insertPosition: 'end' + insertPosition: 'end', }); }); @@ -498,7 +495,7 @@ describe('SettingsManager Integration Tests', () => { // Arrange const apiKey = 'sk-test1234567890abcdefghijklmnopqrstuvwxyz'; await settingsManager.setApiKey(apiKey); - + const savedData = mockPlugin.saveData.mock.calls[0][0]; mockPlugin.loadData.mockResolvedValue(savedData); @@ -538,7 +535,9 @@ describe('SettingsManager Integration Tests', () => { // Assert expect(validation.valid).toBe(false); expect(validation.errors).toContain('API key is not configured'); - expect(validation.errors).toContain('Invalid max file size (must be between 0 and 25MB)'); + expect(validation.errors).toContain( + 'Invalid max file size (must be between 0 and 25MB)' + ); expect(validation.errors).toContain('Invalid temperature (must be between 0 and 1)'); }); }); @@ -565,7 +564,7 @@ describe('SettingsManager Integration Tests', () => { const imported = { apiKey: 'sk-imported9876543210zyxwvutsrqponmlkjihgfedcba', - language: 'en' + language: 'en', }; // Act @@ -577,4 +576,4 @@ describe('SettingsManager Integration Tests', () => { expect(settingsManager.get('language')).toBe('en'); // 다른 설정은 변경 }); }); -}); \ No newline at end of file +}); diff --git a/tests/integration/api.integration.test.ts b/tests/integration/api.integration.test.ts index 78cbea3..29b3970 100644 --- a/tests/integration/api.integration.test.ts +++ b/tests/integration/api.integration.test.ts @@ -16,14 +16,14 @@ import { createMockVault, createMockSettings, createMockWhisperResponse, - createMockWAVBuffer + createMockWAVBuffer, } from '../helpers/mockDataFactory'; import '../helpers/testSetup'; // Mock Obsidian's requestUrl jest.mock('obsidian', () => ({ ...jest.requireActual('obsidian'), - requestUrl: jest.fn() + requestUrl: jest.fn(), })); describe('API Integration Tests', () => { @@ -44,14 +44,14 @@ describe('API Integration Tests', () => { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), - error: jest.fn() + error: jest.fn(), }; mockEventManager = { emit: jest.fn(), on: jest.fn(), off: jest.fn(), - once: jest.fn() + once: jest.fn(), }; // Initialize services @@ -59,7 +59,7 @@ describe('API Integration Tests', () => { fileUploadManager = new FileUploadManager(mockVault as Vault, mockLogger); audioProcessor = new AudioProcessor(mockVault as Vault, mockLogger); textFormatter = new TextFormatter(mockSettings); - + transcriptionService = new TranscriptionService( whisperService, audioProcessor, @@ -80,18 +80,18 @@ describe('API Integration Tests', () => { const file = createMockAudioFile({ name: 'test-audio.mp3', extension: 'mp3', - size: 2 * 1024 * 1024 // 2MB + size: 2 * 1024 * 1024, // 2MB }); const mockBuffer = createMockWAVBuffer(44100, 5); const mockWhisperResponse = createMockWhisperResponse({ text: '통합 테스트 결과입니다.', - language: 'ko' + language: 'ko', }); (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: mockWhisperResponse + json: mockWhisperResponse, }); // Execute @@ -110,19 +110,19 @@ describe('API Integration Tests', () => { const file = createMockAudioFile({ name: 'large-audio.wav', extension: 'wav', - size: 30 * 1024 * 1024 // 30MB + size: 30 * 1024 * 1024, // 30MB }); const mockBuffer = createMockWAVBuffer(44100, 700); // Large WAV (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); // Execute const processedFile = await fileUploadManager.prepareAudioFile(file); - + // Verify compression occurred expect(processedFile.compressed).toBe(true); expect(processedFile.processedSize).toBeLessThan(processedFile.originalSize); @@ -140,7 +140,7 @@ describe('API Integration Tests', () => { (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); // Capture progress @@ -150,8 +150,8 @@ describe('API Integration Tests', () => { // Verify progress updates expect(progressUpdates.length).toBeGreaterThan(0); - expect(progressUpdates.some(p => p.status === 'preparing')).toBe(true); - expect(progressUpdates.some(p => p.status === 'completed')).toBe(true); + expect(progressUpdates.some((p) => p.status === 'preparing')).toBe(true); + expect(progressUpdates.some((p) => p.status === 'completed')).toBe(true); // Continue with transcription const result = await whisperService.transcribe(processedFile.buffer); @@ -165,7 +165,7 @@ describe('API Integration Tests', () => { (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 401, - json: { error: { message: 'Invalid API key' } } + json: { error: { message: 'Invalid API key' } }, }); // File processing should succeed @@ -173,13 +173,14 @@ describe('API Integration Tests', () => { expect(processedFile).toBeDefined(); // But transcription should fail with auth error - await expect(whisperService.transcribe(processedFile.buffer)) - .rejects.toThrow('Invalid API key'); + await expect(whisperService.transcribe(processedFile.buffer)).rejects.toThrow( + 'Invalid API key' + ); }); it('should handle cancellation during processing', async () => { const file = createMockAudioFile({ - size: 10 * 1024 * 1024 // 10MB + size: 10 * 1024 * 1024, // 10MB }); const mockBuffer = createMockArrayBuffer(10 * 1024 * 1024); @@ -201,7 +202,7 @@ describe('API Integration Tests', () => { const file = createMockAudioFile({ name: 'speech.mp3', extension: 'mp3', - size: 3 * 1024 * 1024 + size: 3 * 1024 * 1024, }); const mockBuffer = createMockArrayBuffer(3 * 1024 * 1024); const mockWhisperResponse = createMockWhisperResponse({ @@ -209,14 +210,14 @@ describe('API Integration Tests', () => { language: 'ko', segments: [ { start: 0, end: 2, text: '안녕하세요.' }, - { start: 2, end: 5, text: '음성 인식 테스트입니다.' } - ] + { start: 2, end: 5, text: '음성 인식 테스트입니다.' }, + ], }); (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: mockWhisperResponse + json: mockWhisperResponse, }); // Execute full pipeline @@ -241,11 +242,12 @@ describe('API Integration Tests', () => { it('should handle validation errors in pipeline', async () => { const file = createMockAudioFile({ extension: 'txt', // Invalid format - size: 1024 + size: 1024, }); - await expect(transcriptionService.transcribe(file)) - .rejects.toThrow('File validation failed'); + await expect(transcriptionService.transcribe(file)).rejects.toThrow( + 'File validation failed' + ); expect(mockEventManager.emit).toHaveBeenCalledWith( 'transcription:error', @@ -260,15 +262,14 @@ describe('API Integration Tests', () => { (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockRejectedValue(new Error('Network error')); - await expect(transcriptionService.transcribe(file)) - .rejects.toThrow('Network error'); + await expect(transcriptionService.transcribe(file)).rejects.toThrow('Network error'); expect(mockEventManager.emit).toHaveBeenCalledWith( 'transcription:error', expect.objectContaining({ error: expect.objectContaining({ - message: 'Network error' - }) + message: 'Network error', + }), }) ); }); @@ -277,13 +278,13 @@ describe('API Integration Tests', () => { const file = createMockAudioFile(); const mockBuffer = createMockArrayBuffer(1024); const mockWhisperResponse = createMockWhisperResponse({ - text: ' 많은 공백이 있는 텍스트입니다. ' + text: ' 많은 공백이 있는 텍스트입니다. ', }); (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: mockWhisperResponse + json: mockWhisperResponse, }); const result = await transcriptionService.transcribe(file); @@ -297,7 +298,7 @@ describe('API Integration Tests', () => { // Update settings for timestamp mockSettings.timestampFormat = 'inline'; textFormatter = new TextFormatter(mockSettings); - + transcriptionService = new TranscriptionService( whisperService, audioProcessor, @@ -310,15 +311,13 @@ describe('API Integration Tests', () => { const mockBuffer = createMockArrayBuffer(1024); const mockWhisperResponse = createMockWhisperResponse({ text: '타임스탬프 테스트', - segments: [ - { start: 0, end: 3, text: '타임스탬프 테스트' } - ] + segments: [{ start: 0, end: 3, text: '타임스탬프 테스트' }], }); (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: mockWhisperResponse + json: mockWhisperResponse, }); const result = await transcriptionService.transcribe(file); @@ -334,18 +333,18 @@ describe('API Integration Tests', () => { const mockBuffer = createMockArrayBuffer(1024); (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); - + // First call fails, second succeeds (requestUrl as jest.Mock) .mockRejectedValueOnce(new Error('Temporary error')) .mockResolvedValueOnce({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); // Should eventually succeed with retry const processedFile = await fileUploadManager.prepareAudioFile(file); - + // Note: Retry logic is in WhisperService // This would require retry strategy to be properly mocked expect(processedFile).toBeDefined(); @@ -359,13 +358,14 @@ describe('API Integration Tests', () => { (requestUrl as jest.Mock).mockResolvedValue({ status: 429, json: { error: { message: 'Rate limit exceeded' } }, - headers: { 'retry-after': '5' } + headers: { 'retry-after': '5' }, }); const processedFile = await fileUploadManager.prepareAudioFile(file); - - await expect(whisperService.transcribe(processedFile.buffer)) - .rejects.toThrow('Rate limit exceeded'); + + await expect(whisperService.transcribe(processedFile.buffer)).rejects.toThrow( + 'Rate limit exceeded' + ); }); }); @@ -374,23 +374,23 @@ describe('API Integration Tests', () => { const files = [ createMockAudioFile({ name: 'file1.mp3' }), createMockAudioFile({ name: 'file2.mp3' }), - createMockAudioFile({ name: 'file3.mp3' }) + createMockAudioFile({ name: 'file3.mp3' }), ]; const mockBuffer = createMockArrayBuffer(1024); (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); // Process files concurrently const results = await Promise.all( - files.map(file => transcriptionService.transcribe(file)) + files.map((file) => transcriptionService.transcribe(file)) ); expect(results).toHaveLength(3); - results.forEach(result => { + results.forEach((result) => { expect(result.text).toBeDefined(); expect(result.language).toBeDefined(); }); @@ -403,19 +403,19 @@ describe('API Integration Tests', () => { const files = [ createMockAudioFile({ name: 'success.mp3' }), createMockAudioFile({ name: 'failure.txt', extension: 'txt' }), // Will fail validation - createMockAudioFile({ name: 'success2.mp3' }) + createMockAudioFile({ name: 'success2.mp3' }), ]; const mockBuffer = createMockArrayBuffer(1024); (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); // Process files concurrently with error handling const results = await Promise.allSettled( - files.map(file => transcriptionService.transcribe(file)) + files.map((file) => transcriptionService.transcribe(file)) ); expect(results[0].status).toBe('fulfilled'); @@ -427,20 +427,20 @@ describe('API Integration Tests', () => { describe('Performance and Memory Management', () => { it('should handle large files without memory issues', async () => { const file = createMockAudioFile({ - size: 24 * 1024 * 1024 // 24MB - just under limit + size: 24 * 1024 * 1024, // 24MB - just under limit }); const mockBuffer = createMockArrayBuffer(24 * 1024 * 1024); (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); const startMemory = process.memoryUsage().heapUsed; - + const result = await transcriptionService.transcribe(file); - + const endMemory = process.memoryUsage().heapUsed; const memoryIncrease = (endMemory - startMemory) / 1024 / 1024; // MB @@ -456,7 +456,7 @@ describe('API Integration Tests', () => { (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); await transcriptionService.transcribe(file); @@ -469,4 +469,4 @@ describe('API Integration Tests', () => { expect((fileUploadManager as any).abortController).toBeUndefined(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/mocks/obsidian.mock.ts b/tests/mocks/obsidian.mock.ts index 16bb1ee..8d564cd 100644 --- a/tests/mocks/obsidian.mock.ts +++ b/tests/mocks/obsidian.mock.ts @@ -16,7 +16,7 @@ const obsidianMock = { Notice: fn(), DropdownComponent: fn(), ToggleComponent: fn(), - TextComponent: fn() + TextComponent: fn(), }; export = obsidianMock; diff --git a/tests/performance/performance-optimization.test.ts b/tests/performance/performance-optimization.test.ts index 0fc0605..7a132a6 100644 --- a/tests/performance/performance-optimization.test.ts +++ b/tests/performance/performance-optimization.test.ts @@ -1,6 +1,6 @@ /** * Phase 4 Performance Optimization Tests - * + * * 성능 최적화 구현 테스트 */ @@ -12,28 +12,24 @@ import { ObjectPool, bufferPool } from '../../src/utils/memory/ObjectPool'; import { PerformanceBenchmark } from '../../src/utils/performance/PerformanceBenchmark'; describe('Phase 4 Performance Optimization', () => { - describe('Bundle Size Optimization', () => { test('Lazy loading should reduce initial bundle size', async () => { // 초기 로드된 모듈 수 확인 const initialStats = LazyLoader.getStats(); expect(initialStats.loadedCount).toBe(0); - + // 모듈 동적 로드 const module = await LazyLoader.loadModule('StatisticsDashboard'); expect(module).toBeDefined(); - + // 로드 후 상태 확인 const afterStats = LazyLoader.getStats(); expect(afterStats.loadedCount).toBe(1); }); - + test('Module preloading should work in background', (done) => { - LazyLoader.preloadModules([ - 'AdvancedSettings', - 'AudioSettings' - ]); - + LazyLoader.preloadModules(['AdvancedSettings', 'AudioSettings']); + // 백그라운드 로드 확인 setTimeout(() => { const stats = LazyLoader.getStats(); @@ -42,232 +38,235 @@ describe('Phase 4 Performance Optimization', () => { }, 100); }); }); - + describe('Memory Cache System', () => { let cache: MemoryCache; - + beforeEach(() => { cache = new MemoryCache({ maxSize: 10, - ttl: 1000 + ttl: 1000, }); }); - + afterEach(() => { cache.destroy(); }); - + test('Should cache and retrieve data', () => { const data = { test: 'value' }; cache.set('key1', data); - + const retrieved = cache.get('key1'); expect(retrieved).toEqual(data); - + const stats = cache.getStats(); expect(stats.hits).toBe(1); expect(stats.hitRate).toBeGreaterThan(0); }); - + test('Should respect TTL', async () => { cache.set('key2', 'value', 100); // 100ms TTL - + expect(cache.get('key2')).toBe('value'); - - await new Promise(resolve => setTimeout(resolve, 150)); - + + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(cache.get('key2')).toBeUndefined(); }); - + test('Should implement LRU eviction', () => { // Fill cache to max for (let i = 0; i < 10; i++) { cache.set(`key${i}`, `value${i}`); } - + // Add one more - should evict least recently used cache.set('key10', 'value10'); - + expect(cache.has('key0')).toBe(false); // First one should be evicted expect(cache.has('key10')).toBe(true); }); - + test('Should invalidate by pattern', () => { cache.set('api.user.1', 'user1'); cache.set('api.user.2', 'user2'); cache.set('api.post.1', 'post1'); - + const invalidated = cache.invalidate('api.user.*'); - + expect(invalidated).toBe(2); expect(cache.has('api.user.1')).toBe(false); expect(cache.has('api.user.2')).toBe(false); expect(cache.has('api.post.1')).toBe(true); }); }); - + describe('Batch Request Manager', () => { let batchManager: BatchRequestManager; - + beforeEach(() => { batchManager = new BatchRequestManager({ maxBatchSize: 5, - batchDelay: 50 + batchDelay: 50, }); }); - + afterEach(() => { batchManager.destroy(); }); - + test('Should batch multiple requests', async () => { const mockFetch = jest.spyOn(global, 'fetch').mockImplementation(() => Promise.resolve({ ok: true, - json: () => Promise.resolve({ - responses: [ - { id: '1', success: true, data: 'result1' }, - { id: '2', success: true, data: 'result2' } - ] - }) + json: () => + Promise.resolve({ + responses: [ + { id: '1', success: true, data: 'result1' }, + { id: '2', success: true, data: 'result2' }, + ], + }), } as Response) ); - + // Add multiple requests const promise1 = batchManager.addRequest('/api/test', 'GET'); const promise2 = batchManager.addRequest('/api/test', 'GET'); - + // Wait for batch processing - await new Promise(resolve => setTimeout(resolve, 100)); - + await new Promise((resolve) => setTimeout(resolve, 100)); + // Verify only one batch request was made expect(mockFetch).toHaveBeenCalledTimes(1); - + mockFetch.mockRestore(); }); - + test('Should handle priority queuing', () => { const pendingBefore = batchManager.getPendingCount(); - + batchManager.addRequest('/api/test', 'GET', {}, { priority: 'low' }); batchManager.addRequest('/api/test', 'GET', {}, { priority: 'high' }); batchManager.addRequest('/api/test', 'GET', {}, { priority: 'normal' }); - + const pendingAfter = batchManager.getPendingCount(); expect(pendingAfter).toBeGreaterThan(pendingBefore); }); }); - + describe('Memory Profiler', () => { let profiler: MemoryProfiler; - + beforeEach(() => { profiler = MemoryProfiler.getInstance(100); // 100ms interval for testing }); - + afterEach(() => { profiler.destroy(); }); - + test('Should detect memory usage', () => { profiler.startProfiling(); - + const stats = profiler.getStats(); expect(stats.monitoring).toBe(true); - + profiler.stopProfiling(); }); - + test('Should generate memory report', () => { const report = profiler.generateReport(); - + expect(report).toHaveProperty('currentUsage'); expect(report).toHaveProperty('trend'); expect(report).toHaveProperty('leaks'); expect(report).toHaveProperty('recommendations'); expect(report).toHaveProperty('healthScore'); - + expect(report.healthScore).toBeGreaterThanOrEqual(0); expect(report.healthScore).toBeLessThanOrEqual(100); }); - + test('Should register leak callbacks', (done) => { profiler.onLeakDetected((leak) => { expect(leak).toHaveProperty('type'); expect(leak).toHaveProperty('severity'); done(); }); - + // Simulate leak detection would happen during profiling // For testing, we just verify the callback is registered expect(profiler.getStats()).toBeDefined(); done(); }); }); - + describe('Object Pool', () => { test('Should reuse objects from pool', () => { const pool = new ObjectPool({ factory: () => [], - reset: (arr) => { arr.length = 0; }, + reset: (arr) => { + arr.length = 0; + }, minSize: 2, - maxSize: 5 + maxSize: 5, }); - + const arr1 = pool.acquire(); arr1.push(1, 2, 3); - + pool.release(arr1); - + const arr2 = pool.acquire(); expect(arr2.length).toBe(0); // Should be reset - + const stats = pool.getStats(); expect(stats.reused).toBeGreaterThan(0); - + pool.destroy(); }); - + test('Buffer pool should manage ArrayBuffers', () => { const buffer1 = bufferPool.acquire(); expect(buffer1).toBeInstanceOf(ArrayBuffer); expect(buffer1.byteLength).toBe(1024 * 1024); // 1MB - + bufferPool.release(buffer1); - + const buffer2 = bufferPool.acquire(); // Should get the same buffer back - + const stats = bufferPool.getStats(); expect(stats.hitRate).toBeGreaterThan(0); }); - + test('Should respect pool size limits', () => { const pool = new ObjectPool({ factory: () => ({}), reset: () => {}, - maxSize: 3 + maxSize: 3, }); - + const objects = []; for (let i = 0; i < 5; i++) { objects.push(pool.acquire()); } - - objects.forEach(obj => pool.release(obj)); - + + objects.forEach((obj) => pool.release(obj)); + const stats = pool.getStats(); expect(stats.available).toBeLessThanOrEqual(3); - + pool.destroy(); }); }); - + describe('Performance Benchmark', () => { beforeEach(() => { PerformanceBenchmark.clear(); }); - + test('Should measure sync function performance', () => { const result = PerformanceBenchmark.measureSync('test.sync', () => { let sum = 0; @@ -276,113 +275,115 @@ describe('Phase 4 Performance Optimization', () => { } return sum; }); - + expect(result).toBe(499500); - + const stats = PerformanceBenchmark.getStats('test.sync'); expect(stats).not.toBeNull(); expect(stats!.count).toBe(1); expect(stats!.mean).toBeGreaterThan(0); }); - + test('Should measure async function performance', async () => { const result = await PerformanceBenchmark.measureAsync('test.async', async () => { - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); return 'done'; }); - + expect(result).toBe('done'); - + const stats = PerformanceBenchmark.getStats('test.async'); expect(stats).not.toBeNull(); expect(stats!.mean).toBeGreaterThanOrEqual(10); }); - + test('Should generate performance report', () => { // Add some measurements for (let i = 0; i < 10; i++) { PerformanceBenchmark.recordMetric('api.call', Math.random() * 100); PerformanceBenchmark.recordMetric('cache.hit', Math.random() * 5); } - + const report = PerformanceBenchmark.generateReport(); - + expect(report).toHaveProperty('timestamp'); expect(report).toHaveProperty('metrics'); expect(report).toHaveProperty('summary'); expect(report).toHaveProperty('recommendations'); - + expect(report.metrics).toHaveProperty('api.call'); expect(report.metrics).toHaveProperty('cache.hit'); - + expect(report.summary.totalMeasurements).toBe(20); }); - + test('Should check performance thresholds', () => { PerformanceBenchmark.setThreshold('custom.metric', 10, 20, 50); - + const consoleSpy = jest.spyOn(console, 'warn'); - + PerformanceBenchmark.recordMetric('custom.metric', 25); // Above warning - + expect(consoleSpy).toHaveBeenCalled(); - + consoleSpy.mockRestore(); }); }); - + describe('Integration Tests', () => { test('Cache + Benchmark integration', async () => { const cache = globalCache; - + // Measure cache performance const setTime = await PerformanceBenchmark.measureAsync('cache.set', async () => { for (let i = 0; i < 100; i++) { cache.set(`key${i}`, `value${i}`); } }); - + const getTime = await PerformanceBenchmark.measureAsync('cache.get', async () => { for (let i = 0; i < 100; i++) { cache.get(`key${i}`); } }); - + expect(setTime).toBeGreaterThan(0); expect(getTime).toBeGreaterThan(0); expect(getTime).toBeLessThan(setTime); // Gets should be faster - + const stats = cache.getStats(); expect(stats.hits).toBeGreaterThan(0); }); - + test('Object Pool + Memory Profiler integration', () => { const profiler = MemoryProfiler.getInstance(); - + // Create and destroy many objects const pool = new ObjectPool({ factory: () => new Array(1000), - reset: (arr) => { arr.length = 0; }, - maxSize: 10 + reset: (arr) => { + arr.length = 0; + }, + maxSize: 10, }); - + // Without pool - would create garbage const arrays1 = []; for (let i = 0; i < 100; i++) { arrays1.push(new Array(1000)); } - + // With pool - reuses objects const arrays2 = []; for (let i = 0; i < 100; i++) { const arr = pool.acquire(); arrays2.push(arr); } - arrays2.forEach(arr => pool.release(arr)); - + arrays2.forEach((arr) => pool.release(arr)); + const poolStats = pool.getStats(); expect(poolStats.reused).toBeGreaterThan(0); - + pool.destroy(); }); }); @@ -395,25 +396,25 @@ describe('Performance Targets Validation', () => { const targetSize = 150 * 1024; expect(targetSize).toBeLessThan(200 * 1024); }); - + test('Memory usage should be under 50MB', () => { if ('memory' in performance) { const memory = (performance as any).memory; const usedMB = memory.usedJSHeapSize / 1024 / 1024; - + // Check current memory usage expect(usedMB).toBeLessThan(100); // Generous limit for tests } }); - + test('Cache hit rate should be above 70%', () => { const cache = new MemoryCache(); - + // Simulate realistic usage for (let i = 0; i < 10; i++) { cache.set(`key${i}`, `value${i}`); } - + // 70% hits, 30% misses for (let i = 0; i < 7; i++) { cache.get(`key${i}`); @@ -421,10 +422,10 @@ describe('Performance Targets Validation', () => { for (let i = 10; i < 13; i++) { cache.get(`key${i}`); // misses } - + const stats = cache.getStats(); expect(stats.hitRate).toBeGreaterThanOrEqual(0.5); // 70% is the target - + cache.destroy(); }); -}); \ No newline at end of file +}); diff --git a/tests/settings/SettingsAPI.test.ts b/tests/settings/SettingsAPI.test.ts index 29aefd1..3be11f2 100644 --- a/tests/settings/SettingsAPI.test.ts +++ b/tests/settings/SettingsAPI.test.ts @@ -30,12 +30,12 @@ const localStorageMock = (() => { key: (index: number) => { const keys = Object.keys(store); return keys[index] || null; - } + }, }; })(); Object.defineProperty(window, 'localStorage', { - value: localStorageMock + value: localStorageMock, }); describe('SettingsAPI', () => { @@ -72,9 +72,9 @@ describe('SettingsAPI', () => { notifications: { enabled: false, sound: true, - position: 'bottom-right' as const - } - } + position: 'bottom-right' as const, + }, + }, }; localStorageMock.setItem('speech-to-text-settings', JSON.stringify(savedSettings)); @@ -147,9 +147,9 @@ describe('SettingsAPI', () => { notifications: { enabled: false, sound: false, - position: 'top-left' - } - } + position: 'top-left', + }, + }, }); const general = await settingsAPI.get('general'); @@ -167,7 +167,11 @@ describe('SettingsAPI', () => { await settingsAPI.set('general', general); expect(changeHandler).toHaveBeenCalled(); - expect(changeHandler).toHaveBeenCalledWith('general', expect.any(Object), expect.any(Object)); + expect(changeHandler).toHaveBeenCalledWith( + 'general', + expect.any(Object), + expect.any(Object) + ); }); }); @@ -182,9 +186,9 @@ describe('SettingsAPI', () => { notifications: { enabled: true, sound: false, - position: 'top-right' - } - } + position: 'top-right', + }, + }, }); expect(result.valid).toBe(true); @@ -201,9 +205,9 @@ describe('SettingsAPI', () => { notifications: { enabled: true, sound: false, - position: 'top-right' - } - } + position: 'top-right', + }, + }, }); expect(result.valid).toBe(false); @@ -216,7 +220,7 @@ describe('SettingsAPI', () => { provider: 'invalid-provider', model: 'whisper-1', maxTokens: -100, - temperature: 3 + temperature: 3, }); expect(result.valid).toBe(false); @@ -226,9 +230,12 @@ describe('SettingsAPI', () => { describe('설정 마이그레이션', () => { it('버전 마이그레이션이 필요한지 확인해야 함', () => { - localStorageMock.setItem('speech-to-text-settings', JSON.stringify({ - version: '2.0.0' - })); + localStorageMock.setItem( + 'speech-to-text-settings', + JSON.stringify({ + version: '2.0.0', + }) + ); const needsMigration = settingsAPI.needsMigration(); expect(needsMigration).toBe(true); @@ -288,16 +295,14 @@ describe('SettingsAPI', () => { notifications: { enabled: false, sound: true, - position: 'bottom-left' as const - } - } + position: 'bottom-left' as const, + }, + }, }; - const file = new File( - [JSON.stringify(importData)], - 'settings.json', - { type: 'application/json' } - ); + const file = new File([JSON.stringify(importData)], 'settings.json', { + type: 'application/json', + }); const result = await settingsAPI.import(file, { merge: true }); @@ -310,11 +315,9 @@ describe('SettingsAPI', () => { it('유효하지 않은 파일 가져오기를 거부해야 함', async () => { const invalidData = { invalid: 'data' }; - const file = new File( - [JSON.stringify(invalidData)], - 'invalid.json', - { type: 'application/json' } - ); + const file = new File([JSON.stringify(invalidData)], 'invalid.json', { + type: 'application/json', + }); const result = await settingsAPI.import(file, { validate: true }); @@ -522,8 +525,8 @@ describe('SettingsValidator', () => { notifications: { enabled: true, sound: false, - position: 'top-right' - } + position: 'top-right', + }, }); expect(result.valid).toBe(true); @@ -531,7 +534,7 @@ describe('SettingsValidator', () => { it('유효하지 않은 언어 코드를 거부해야 함', () => { const result = validator.validateField('general', { - language: 'invalid-lang' + language: 'invalid-lang', }); expect(result.valid).toBe(false); @@ -540,7 +543,7 @@ describe('SettingsValidator', () => { it('너무 짧은 저장 간격에 대해 경고해야 함', () => { const result = validator.validateField('general', { - saveInterval: 5000 + saveInterval: 5000, }); expect(result.valid).toBe(true); @@ -554,7 +557,7 @@ describe('SettingsValidator', () => { provider: 'openai', model: 'whisper-1', maxTokens: 4096, - temperature: 0.7 + temperature: 0.7, }); expect(result.valid).toBe(true); @@ -562,7 +565,7 @@ describe('SettingsValidator', () => { it('커스텀 프로바이더에 엔드포인트가 없으면 거부해야 함', () => { const result = validator.validateField('api', { - provider: 'custom' + provider: 'custom', }); expect(result.valid).toBe(false); @@ -571,7 +574,7 @@ describe('SettingsValidator', () => { it('유효하지 않은 temperature를 거부해야 함', () => { const result = validator.validateField('api', { - temperature: 3 + temperature: 3, }); expect(result.valid).toBe(false); @@ -581,7 +584,9 @@ describe('SettingsValidator', () => { describe('API 키 검증', () => { it('유효한 OpenAI API 키를 통과시켜야 함', () => { - const result = SettingsValidator.validateApiKey('sk-test1234567890abcdefghijklmnopqrstuvwxyz'); + const result = SettingsValidator.validateApiKey( + 'sk-test1234567890abcdefghijklmnopqrstuvwxyz' + ); expect(result.valid).toBe(true); }); @@ -625,12 +630,12 @@ describe('SettingsMigrator', () => { version: '2.0.0', general: { language: 'ko', - autoSave: true + autoSave: true, }, api: { apiKey: 'sk-old-key', - model: 'whisper-1' - } + model: 'whisper-1', + }, }; const migrated = await migrator.migrate(oldSettings, '2.0.0', '3.0.0'); @@ -643,7 +648,7 @@ describe('SettingsMigrator', () => { it('백업을 생성할 수 있어야 함', async () => { const settings = { version: '3.0.0', - general: { language: 'ko' } + general: { language: 'ko' }, }; const backupKey = await migrator.createBackup(settings); @@ -655,7 +660,7 @@ describe('SettingsMigrator', () => { it('백업을 복원할 수 있어야 함', async () => { const settings = { version: '3.0.0', - general: { language: 'ko' } + general: { language: 'ko' }, }; const backupKey = await migrator.createBackup(settings); @@ -664,4 +669,4 @@ describe('SettingsMigrator', () => { expect(restored.version).toBe('3.0.0'); expect(restored.general.language).toBe('ko'); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/AudioProcessor.test.ts b/tests/unit/AudioProcessor.test.ts index a98ab9b..e57e010 100644 --- a/tests/unit/AudioProcessor.test.ts +++ b/tests/unit/AudioProcessor.test.ts @@ -8,7 +8,7 @@ import type { ILogger } from '../../src/types'; import { createMockAudioFile, createMockArrayBuffer, - createMockVault + createMockVault, } from '../helpers/mockDataFactory'; import '../helpers/testSetup'; @@ -23,7 +23,7 @@ describe('AudioProcessor', () => { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), - error: jest.fn() + error: jest.fn(), }; audioProcessor = new AudioProcessor(mockVault as Vault, mockLogger); @@ -41,7 +41,7 @@ describe('AudioProcessor', () => { const file = createMockAudioFile({ name: `test.${format}`, extension: format, - size: 5 * 1024 * 1024 // 5MB + size: 5 * 1024 * 1024, // 5MB }); const result = await audioProcessor.validate(file); @@ -57,7 +57,7 @@ describe('AudioProcessor', () => { for (const format of invalidFormats) { const file = createMockAudioFile({ name: `test.${format}`, - extension: format + extension: format, }); const result = await audioProcessor.validate(file); @@ -70,7 +70,7 @@ describe('AudioProcessor', () => { it('should reject files exceeding maximum size', async () => { const file = createMockAudioFile({ - size: 30 * 1024 * 1024 // 30MB + size: 30 * 1024 * 1024, // 30MB }); const result = await audioProcessor.validate(file); @@ -82,7 +82,7 @@ describe('AudioProcessor', () => { it('should add warning for large files', async () => { const file = createMockAudioFile({ - size: 15 * 1024 * 1024 // 15MB + size: 15 * 1024 * 1024, // 15MB }); const result = await audioProcessor.validate(file); @@ -95,7 +95,7 @@ describe('AudioProcessor', () => { it('should handle edge case file sizes', async () => { // Exactly at limit const fileAtLimit = createMockAudioFile({ - size: 25 * 1024 * 1024 // 25MB + size: 25 * 1024 * 1024, // 25MB }); const resultAtLimit = await audioProcessor.validate(fileAtLimit); @@ -103,7 +103,7 @@ describe('AudioProcessor', () => { // Just over limit const fileOverLimit = createMockAudioFile({ - size: 25 * 1024 * 1024 + 1 // 25MB + 1 byte + size: 25 * 1024 * 1024 + 1, // 25MB + 1 byte }); const resultOverLimit = await audioProcessor.validate(fileOverLimit); @@ -117,7 +117,7 @@ describe('AudioProcessor', () => { const file = createMockAudioFile({ name: `test.${format}`, extension: format, - size: 1024 * 1024 + size: 1024 * 1024, }); const result = await audioProcessor.validate(file); @@ -128,7 +128,7 @@ describe('AudioProcessor', () => { describe('provider-specific file size limits', () => { it('should use default 25MB limit by default', async () => { const file = createMockAudioFile({ - size: 30 * 1024 * 1024 // 30MB + size: 30 * 1024 * 1024, // 30MB }); const result = await audioProcessor.validate(file); @@ -141,11 +141,11 @@ describe('AudioProcessor', () => { it('should accept larger files when Deepgram capabilities are set', async () => { // Set Deepgram capabilities (2GB limit) audioProcessor.setProviderCapabilities({ - maxFileSize: 2 * 1024 * 1024 * 1024 // 2GB + maxFileSize: 2 * 1024 * 1024 * 1024, // 2GB }); const file = createMockAudioFile({ - size: 100 * 1024 * 1024 // 100MB + size: 100 * 1024 * 1024, // 100MB }); const result = await audioProcessor.validate(file); @@ -157,11 +157,11 @@ describe('AudioProcessor', () => { it('should reject files exceeding Deepgram 2GB limit', async () => { // Set Deepgram capabilities (2GB limit) audioProcessor.setProviderCapabilities({ - maxFileSize: 2 * 1024 * 1024 * 1024 // 2GB + maxFileSize: 2 * 1024 * 1024 * 1024, // 2GB }); const file = createMockAudioFile({ - size: 2.5 * 1024 * 1024 * 1024 // 2.5GB + size: 2.5 * 1024 * 1024 * 1024, // 2.5GB }); const result = await audioProcessor.validate(file); @@ -173,7 +173,7 @@ describe('AudioProcessor', () => { it('should log capability updates', () => { audioProcessor.setProviderCapabilities({ - maxFileSize: 2 * 1024 * 1024 * 1024 // 2GB + maxFileSize: 2 * 1024 * 1024 * 1024, // 2GB }); expect(mockLogger.debug).toHaveBeenCalledWith( @@ -181,7 +181,7 @@ describe('AudioProcessor', () => { expect.objectContaining({ previousLimit: 25, newLimit: 2048, - provider: 'Deepgram' + provider: 'Deepgram', }) ); }); @@ -189,11 +189,11 @@ describe('AudioProcessor', () => { it('should maintain Whisper 25MB limit when set', async () => { // Set Whisper capabilities (25MB limit) audioProcessor.setProviderCapabilities({ - maxFileSize: 25 * 1024 * 1024 // 25MB + maxFileSize: 25 * 1024 * 1024, // 25MB }); const file = createMockAudioFile({ - size: 30 * 1024 * 1024 // 30MB + size: 30 * 1024 * 1024, // 30MB }); const result = await audioProcessor.validate(file); @@ -209,7 +209,7 @@ describe('AudioProcessor', () => { it('should process valid audio file', async () => { const file = createMockAudioFile(); const mockBuffer = createMockArrayBuffer(1024); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); const result = await audioProcessor.process(file); @@ -224,7 +224,7 @@ describe('AudioProcessor', () => { it('should handle file read errors', async () => { const file = createMockAudioFile(); const error = new Error('Failed to read file'); - + (mockVault.readBinary as jest.Mock).mockRejectedValue(error); await expect(audioProcessor.process(file)).rejects.toThrow(error); @@ -233,7 +233,7 @@ describe('AudioProcessor', () => { it('should extract metadata from audio buffer', async () => { const file = createMockAudioFile(); const mockBuffer = createMockArrayBuffer(2048); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); const result = await audioProcessor.process(file); @@ -258,7 +258,7 @@ describe('AudioProcessor', () => { channels: undefined, codec: undefined, format: undefined, - fileSize: 1024 + fileSize: 1024, }); }); @@ -276,7 +276,7 @@ describe('AudioProcessor', () => { it('should handle files with special characters in name', async () => { const file = createMockAudioFile({ name: 'test file (2024) [edited].mp3', - path: 'folder/test file (2024) [edited].mp3' + path: 'folder/test file (2024) [edited].mp3', }); const result = await audioProcessor.validate(file); @@ -286,7 +286,7 @@ describe('AudioProcessor', () => { it('should handle files with multiple dots in name', async () => { const file = createMockAudioFile({ name: 'test.audio.file.mp3', - extension: 'mp3' + extension: 'mp3', }); const result = await audioProcessor.validate(file); @@ -295,11 +295,11 @@ describe('AudioProcessor', () => { it('should handle zero-size files', async () => { const file = createMockAudioFile({ - size: 0 + size: 0, }); const result = await audioProcessor.validate(file); - + // Zero-size files should be invalid expect(result.valid).toBe(true); // Current implementation doesn't check minimum size }); @@ -308,10 +308,10 @@ describe('AudioProcessor', () => { describe('performance', () => { it('should process files efficiently', async () => { const file = createMockAudioFile({ - size: 10 * 1024 * 1024 // 10MB + size: 10 * 1024 * 1024, // 10MB }); const mockBuffer = createMockArrayBuffer(10 * 1024 * 1024); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); const startTime = Date.now(); @@ -323,23 +323,23 @@ describe('AudioProcessor', () => { }); it('should validate files quickly', async () => { - const files = Array.from({ length: 100 }, (_, i) => + const files = Array.from({ length: 100 }, (_, i) => createMockAudioFile({ name: `test${i}.mp3`, - size: Math.random() * 30 * 1024 * 1024 + size: Math.random() * 30 * 1024 * 1024, }) ); const startTime = Date.now(); - + for (const file of files) { await audioProcessor.validate(file); } - + const endTime = Date.now(); // Validation of 100 files should be fast (less than 50ms) expect(endTime - startTime).toBeLessThan(50); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/EditorService.test.ts b/tests/unit/EditorService.test.ts index 9867fb4..a8b5a1a 100644 --- a/tests/unit/EditorService.test.ts +++ b/tests/unit/EditorService.test.ts @@ -28,37 +28,37 @@ describe('EditorService', () => { getValue: jest.fn(), setValue: jest.fn(), setSelection: jest.fn(), - focus: jest.fn() + focus: jest.fn(), } as any; mockView = { editor: mockEditor, - file: { path: 'test.md' } + file: { path: 'test.md' }, } as any; mockApp = { workspace: { getActiveViewOfType: jest.fn().mockReturnValue(mockView), on: jest.fn(), - openLinkText: jest.fn() + openLinkText: jest.fn(), }, vault: { - create: jest.fn() - } + create: jest.fn(), + }, } as any; mockEventManager = { emit: jest.fn(), on: jest.fn(), off: jest.fn(), - once: jest.fn() + once: jest.fn(), } as any; mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), - error: jest.fn() + error: jest.fn(), } as any; editorService = new EditorService(mockApp, mockEventManager, mockLogger); @@ -123,18 +123,18 @@ describe('EditorService', () => { expect(mockEditor.replaceRange).toHaveBeenCalledWith(text, position); expect(mockEditor.setCursor).toHaveBeenCalledWith({ line: 1, - ch: text.length + ch: text.length, }); expect(mockEventManager.emit).toHaveBeenCalledWith('editor:text-inserted', { text, - position + position, }); }); it('should return false when no active editor', async () => { mockApp.workspace.getActiveViewOfType.mockReturnValue(null); const result = await editorService.insertAtCursor('text'); - + expect(result).toBe(false); expect(mockLogger.warn).toHaveBeenCalled(); }); @@ -145,7 +145,7 @@ describe('EditorService', () => { }); const result = await editorService.insertAtCursor('text'); - + expect(result).toBe(false); expect(mockLogger.error).toHaveBeenCalled(); }); @@ -156,8 +156,9 @@ describe('EditorService', () => { const oldText = 'old text'; const newText = 'new text'; mockEditor.getSelection.mockReturnValue(oldText); - mockEditor.getCursor.mockReturnValueOnce({ line: 1, ch: 0 }) - .mockReturnValueOnce({ line: 1, ch: 8 }); + mockEditor.getCursor + .mockReturnValueOnce({ line: 1, ch: 0 }) + .mockReturnValueOnce({ line: 1, ch: 8 }); const result = await editorService.replaceSelection(newText); @@ -165,7 +166,7 @@ describe('EditorService', () => { expect(mockEditor.replaceSelection).toHaveBeenCalledWith(newText); expect(mockEventManager.emit).toHaveBeenCalledWith('editor:text-replaced', { oldText, - newText + newText, }); }); }); @@ -180,10 +181,10 @@ describe('EditorService', () => { const result = await editorService.appendToDocument(text); expect(result).toBe(true); - expect(mockEditor.replaceRange).toHaveBeenCalledWith( - `\n${text}`, - { line: 10, ch: lastLineContent.length } - ); + expect(mockEditor.replaceRange).toHaveBeenCalledWith(`\n${text}`, { + line: 10, + ch: lastLineContent.length, + }); }); }); @@ -194,10 +195,7 @@ describe('EditorService', () => { const result = await editorService.prependToDocument(text); expect(result).toBe(true); - expect(mockEditor.replaceRange).toHaveBeenCalledWith( - `${text}\n`, - { line: 0, ch: 0 } - ); + expect(mockEditor.replaceRange).toHaveBeenCalledWith(`${text}\n`, { line: 0, ch: 0 }); }); }); @@ -259,11 +257,10 @@ describe('EditorService', () => { const result = await editorService.undo(); expect(result).toBe(true); - expect(mockEditor.replaceRange).toHaveBeenCalledWith( - '', - position, - { line: 1, ch: text.length } - ); + expect(mockEditor.replaceRange).toHaveBeenCalledWith('', position, { + line: 1, + ch: text.length, + }); }); it('should redo insert action', async () => { @@ -297,21 +294,17 @@ describe('EditorService', () => { const fileName = 'test.md'; const content = 'note content'; const mockFile = { path: fileName }; - + mockApp.vault.create.mockResolvedValue(mockFile as any); const result = await editorService.createAndOpenNote(fileName, content); expect(result).toBe(true); expect(mockApp.vault.create).toHaveBeenCalledWith(fileName, content); - expect(mockApp.workspace.openLinkText).toHaveBeenCalledWith( - fileName, - '', - true - ); + expect(mockApp.workspace.openLinkText).toHaveBeenCalledWith(fileName, '', true); expect(mockEventManager.emit).toHaveBeenCalledWith('editor:note-created', { file: mockFile, - content + content, }); }); @@ -388,12 +381,12 @@ describe('EditorService', () => { describe('destroy', () => { it('should clean up resources', () => { editorService.destroy(); - + expect(mockLogger.debug).toHaveBeenCalledWith('EditorService destroyed'); - + // Should return null after destroy const editor = editorService.getActiveEditor(); expect(editor).toBeNull(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/FileUploadManager.test.ts b/tests/unit/FileUploadManager.test.ts index fb0910f..7648e77 100644 --- a/tests/unit/FileUploadManager.test.ts +++ b/tests/unit/FileUploadManager.test.ts @@ -2,14 +2,18 @@ * FileUploadManager 단위 테스트 */ -import { FileUploadManager, UploadProgress, ProcessedAudioFile } from '../../src/infrastructure/api/FileUploadManager'; +import { + FileUploadManager, + UploadProgress, + ProcessedAudioFile, +} from '../../src/infrastructure/api/FileUploadManager'; import { Vault } from 'obsidian'; import type { ILogger } from '../../src/types'; import { createMockAudioFile, createMockArrayBuffer, createMockVault, - createMockWAVBuffer + createMockWAVBuffer, } from '../helpers/mockDataFactory'; import '../helpers/testSetup'; @@ -24,7 +28,7 @@ describe('FileUploadManager', () => { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), - error: jest.fn() + error: jest.fn(), }; fileUploadManager = new FileUploadManager(mockVault as Vault, mockLogger); @@ -40,10 +44,10 @@ describe('FileUploadManager', () => { const file = createMockAudioFile({ name: 'test.mp3', extension: 'mp3', - size: 5 * 1024 * 1024 // 5MB + size: 5 * 1024 * 1024, // 5MB }); const mockBuffer = createMockArrayBuffer(5 * 1024 * 1024); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); const progressUpdates: UploadProgress[] = []; @@ -68,7 +72,7 @@ describe('FileUploadManager', () => { it('should reject unsupported file formats', async () => { const file = createMockAudioFile({ name: 'test.txt', - extension: 'txt' + extension: 'txt', }); await expect(fileUploadManager.prepareAudioFile(file)).rejects.toThrow( @@ -78,7 +82,7 @@ describe('FileUploadManager', () => { it('should reject files that are too small', async () => { const file = createMockAudioFile({ - size: 50 // Too small + size: 50, // Too small }); await expect(fileUploadManager.prepareAudioFile(file)).rejects.toThrow( @@ -88,7 +92,7 @@ describe('FileUploadManager', () => { it('should reject files that are too large', async () => { const file = createMockAudioFile({ - size: 60 * 1024 * 1024 // 60MB - too large even for compression + size: 60 * 1024 * 1024, // 60MB - too large even for compression }); await expect(fileUploadManager.prepareAudioFile(file)).rejects.toThrow( @@ -98,10 +102,10 @@ describe('FileUploadManager', () => { it('should compress large files', async () => { const file = createMockAudioFile({ - size: 30 * 1024 * 1024 // 30MB - needs compression + size: 30 * 1024 * 1024, // 30MB - needs compression }); const mockBuffer = createMockWAVBuffer(44100, 700); // Large WAV file - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); // Mock AudioContext for compression @@ -110,12 +114,12 @@ describe('FileUploadManager', () => { sampleRate: 44100, numberOfChannels: 2, length: 44100 * 700, - getChannelData: jest.fn().mockReturnValue(new Float32Array(44100 * 700)) + getChannelData: jest.fn().mockReturnValue(new Float32Array(44100 * 700)), }; - + (global.AudioContext as jest.Mock).mockImplementation(() => ({ decodeAudioData: jest.fn().mockResolvedValue(mockAudioBuffer), - close: jest.fn() + close: jest.fn(), })); const progressUpdates: UploadProgress[] = []; @@ -130,7 +134,7 @@ describe('FileUploadManager', () => { ); // Check that compression progress was reported - const compressionProgress = progressUpdates.find(p => + const compressionProgress = progressUpdates.find((p) => p.message?.includes('Compressing audio') ); expect(compressionProgress).toBeDefined(); @@ -139,7 +143,7 @@ describe('FileUploadManager', () => { it('should handle file read errors', async () => { const file = createMockAudioFile(); const error = new Error('Failed to read file'); - + (mockVault.readBinary as jest.Mock).mockRejectedValue(error); await expect(fileUploadManager.prepareAudioFile(file)).rejects.toThrow( @@ -150,19 +154,19 @@ describe('FileUploadManager', () => { it('should extract metadata from audio buffer', async () => { const file = createMockAudioFile(); const mockBuffer = createMockWAVBuffer(44100, 10); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); const mockAudioBuffer = { duration: 10, sampleRate: 44100, numberOfChannels: 2, - length: 441000 + length: 441000, }; - + (global.AudioContext as jest.Mock).mockImplementation(() => ({ decodeAudioData: jest.fn().mockResolvedValue(mockAudioBuffer), - close: jest.fn() + close: jest.fn(), })); const result = await fileUploadManager.prepareAudioFile(file); @@ -176,12 +180,12 @@ describe('FileUploadManager', () => { it('should handle metadata extraction failure gracefully', async () => { const file = createMockAudioFile(); const mockBuffer = createMockArrayBuffer(1024); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); - + (global.AudioContext as jest.Mock).mockImplementation(() => ({ decodeAudioData: jest.fn().mockRejectedValue(new Error('Decode failed')), - close: jest.fn() + close: jest.fn(), })); const result = await fileUploadManager.prepareAudioFile(file); @@ -197,10 +201,10 @@ describe('FileUploadManager', () => { it('should validate magic bytes for known formats', async () => { const file = createMockAudioFile({ - extension: 'wav' + extension: 'wav', }); const mockBuffer = createMockWAVBuffer(); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); await fileUploadManager.prepareAudioFile(file); @@ -214,10 +218,10 @@ describe('FileUploadManager', () => { it('should warn about invalid magic bytes', async () => { const file = createMockAudioFile({ - extension: 'wav' + extension: 'wav', }); const mockBuffer = createMockArrayBuffer(1024); // Not a real WAV - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); await fileUploadManager.prepareAudioFile(file); @@ -225,7 +229,7 @@ describe('FileUploadManager', () => { expect(mockLogger.warn).toHaveBeenCalledWith( 'File content does not match expected format', expect.objectContaining({ - extension: 'wav' + extension: 'wav', }) ); }); @@ -267,10 +271,10 @@ describe('FileUploadManager', () => { // Start iteration const iterator = fileUploadManager.uploadInChunks(buffer, chunkSize); - + // Get first chunk await iterator.next(); - + // Cancel fileUploadManager.cancel(); @@ -301,7 +305,7 @@ describe('FileUploadManager', () => { it('should close audio context if exists', () => { const mockClose = jest.fn(); (fileUploadManager as any).audioContext = { - close: mockClose + close: mockClose, }; fileUploadManager.cleanup(); @@ -322,15 +326,15 @@ describe('FileUploadManager', () => { describe('supported formats', () => { const supportedFormats = ['m4a', 'mp3', 'wav', 'mp4', 'mpeg', 'mpga', 'webm', 'ogg']; - supportedFormats.forEach(format => { + supportedFormats.forEach((format) => { it(`should support ${format} format`, async () => { const file = createMockAudioFile({ name: `test.${format}`, extension: format, - size: 1024 * 1024 + size: 1024 * 1024, }); const mockBuffer = createMockArrayBuffer(1024 * 1024); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); const result = await fileUploadManager.prepareAudioFile(file); @@ -344,10 +348,10 @@ describe('FileUploadManager', () => { describe('edge cases', () => { it('should handle exactly 25MB file', async () => { const file = createMockAudioFile({ - size: 25 * 1024 * 1024 // Exactly 25MB + size: 25 * 1024 * 1024, // Exactly 25MB }); const mockBuffer = createMockArrayBuffer(25 * 1024 * 1024); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); const result = await fileUploadManager.prepareAudioFile(file); @@ -357,10 +361,10 @@ describe('FileUploadManager', () => { it('should compress file just over 25MB', async () => { const file = createMockAudioFile({ - size: 25 * 1024 * 1024 + 1 // 25MB + 1 byte + size: 25 * 1024 * 1024 + 1, // 25MB + 1 byte }); const mockBuffer = createMockArrayBuffer(25 * 1024 * 1024 + 1); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); const progressUpdates: UploadProgress[] = []; @@ -374,7 +378,7 @@ describe('FileUploadManager', () => { it('should handle empty progress callback', async () => { const file = createMockAudioFile(); const mockBuffer = createMockArrayBuffer(1024); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); // No progress callback provided @@ -386,11 +390,11 @@ describe('FileUploadManager', () => { it('should report error in progress', async () => { const file = createMockAudioFile(); const error = new Error('Test error'); - + (mockVault.readBinary as jest.Mock).mockRejectedValue(error); const progressUpdates: UploadProgress[] = []; - + await expect( fileUploadManager.prepareAudioFile(file, (progress) => { progressUpdates.push(progress); @@ -398,7 +402,7 @@ describe('FileUploadManager', () => { ).rejects.toThrow(error); // Check that error was reported in progress - const errorProgress = progressUpdates.find(p => p.status === 'error'); + const errorProgress = progressUpdates.find((p) => p.status === 'error'); expect(errorProgress).toBeDefined(); expect(errorProgress?.message).toBe('Test error'); }); @@ -407,10 +411,10 @@ describe('FileUploadManager', () => { describe('performance', () => { it('should process large files efficiently', async () => { const file = createMockAudioFile({ - size: 20 * 1024 * 1024 // 20MB + size: 20 * 1024 * 1024, // 20MB }); const mockBuffer = createMockArrayBuffer(20 * 1024 * 1024); - + (mockVault.readBinary as jest.Mock).mockResolvedValue(mockBuffer); const startTime = Date.now(); @@ -427,15 +431,15 @@ describe('FileUploadManager', () => { const startTime = Date.now(); const chunks: ArrayBuffer[] = []; - + for await (const chunk of fileUploadManager.uploadInChunks(buffer, chunkSize)) { chunks.push(chunk); } - + const endTime = Date.now(); expect(chunks.length).toBe(10); expect(endTime - startTime).toBeLessThan(100); // Should be very fast }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/TextFormatter.test.ts b/tests/unit/TextFormatter.test.ts index ff86e80..78ff922 100644 --- a/tests/unit/TextFormatter.test.ts +++ b/tests/unit/TextFormatter.test.ts @@ -3,11 +3,11 @@ */ import { TextFormatter } from '../../src/core/transcription/TextFormatter'; -import { +import { createMockSettings, createMockFormatOptions, createMockTranscriptionSegment, - createMockSegments + createMockSegments, } from '../helpers/mockDataFactory'; import '../helpers/testSetup'; @@ -63,7 +63,7 @@ describe('TextFormatter', () => { const input = '테스트 텍스트'; const options = createMockFormatOptions({ includeTimestamps: true, - cleanupText: false + cleanupText: false, }); const result = textFormatter.format(input, options); @@ -88,7 +88,7 @@ describe('TextFormatter', () => { const segments = [ createMockTranscriptionSegment({ start: 0, end: 5, text: '첫 번째 세그먼트' }), - createMockTranscriptionSegment({ start: 5, end: 10, text: '두 번째 세그먼트' }) + createMockTranscriptionSegment({ start: 5, end: 10, text: '두 번째 세그먼트' }), ]; const result = textFormatter.insertTimestamps('', segments); @@ -103,7 +103,7 @@ describe('TextFormatter', () => { textFormatter = new TextFormatter(mockSettings); const segments = [ - createMockTranscriptionSegment({ start: 30, end: 35, text: '텍스트' }) + createMockTranscriptionSegment({ start: 30, end: 35, text: '텍스트' }), ]; const result = textFormatter.insertTimestamps('', segments); @@ -121,7 +121,7 @@ describe('TextFormatter', () => { // Should not contain timestamp markers expect(result).not.toContain('['); expect(result).not.toContain('|'); - segments.forEach(segment => { + segments.forEach((segment) => { expect(result).toContain(segment.text); }); }); @@ -145,11 +145,11 @@ describe('TextFormatter', () => { textFormatter = new TextFormatter(mockSettings); const segments = [ - createMockTranscriptionSegment({ + createMockTranscriptionSegment({ start: 3665, // 1:01:05 - end: 3670, - text: '1시간 넘은 세그먼트' - }) + end: 3670, + text: '1시간 넘은 세그먼트', + }), ]; const result = textFormatter.insertTimestamps('', segments); @@ -162,11 +162,11 @@ describe('TextFormatter', () => { textFormatter = new TextFormatter(mockSettings); const segments = [ - createMockTranscriptionSegment({ + createMockTranscriptionSegment({ start: 65.7, // Should be 01:05 - end: 70, - text: '소수점 초' - }) + end: 70, + text: '소수점 초', + }), ]; const result = textFormatter.insertTimestamps('', segments); @@ -261,7 +261,7 @@ describe('TextFormatter', () => { const segments = [ createMockTranscriptionSegment({ start: 0, end: 5, text: '첫 번째' }), createMockTranscriptionSegment({ start: 3, end: 8, text: '겹치는' }), // Overlapping - createMockTranscriptionSegment({ start: 8, end: 12, text: '세 번째' }) + createMockTranscriptionSegment({ start: 8, end: 12, text: '세 번째' }), ]; const result = textFormatter.insertTimestamps('', segments); @@ -273,9 +273,7 @@ describe('TextFormatter', () => { describe('performance', () => { it('should format large text efficiently', () => { - const largeText = Array.from({ length: 10000 }, () => - '문장입니다. ' - ).join('\n'); + const largeText = Array.from({ length: 10000 }, () => '문장입니다. ').join('\n'); const startTime = Date.now(); const result = textFormatter.format(largeText); @@ -303,17 +301,19 @@ describe('TextFormatter', () => { describe('configuration variations', () => { it('should respect different timestamp formats', () => { - const segments = [createMockTranscriptionSegment({ start: 10, end: 15, text: '테스트' })]; + const segments = [ + createMockTranscriptionSegment({ start: 10, end: 15, text: '테스트' }), + ]; // Test each format const formats = ['none', 'inline', 'sidebar'] as const; - + for (const format of formats) { mockSettings.timestampFormat = format; textFormatter = new TextFormatter(mockSettings); - + const result = textFormatter.insertTimestamps('', segments); - + if (format === 'none') { expect(result).toBe('테스트'); } else if (format === 'inline') { @@ -324,4 +324,4 @@ describe('TextFormatter', () => { } }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/TranscriptionService.test.ts b/tests/unit/TranscriptionService.test.ts index d8fe097..9787b36 100644 --- a/tests/unit/TranscriptionService.test.ts +++ b/tests/unit/TranscriptionService.test.ts @@ -9,7 +9,7 @@ import type { ITextFormatter, IEventManager, ILogger, - TranscriptionStatus + TranscriptionStatus, } from '../../src/types'; import { createMockAudioFile, @@ -18,7 +18,7 @@ import { createMockValidationResult, createMockProcessedAudio, createMockTranscriptionResult, - createMockError + createMockError, } from '../helpers/mockDataFactory'; import '../helpers/testSetup'; @@ -35,33 +35,33 @@ describe('TranscriptionService', () => { mockWhisperService = { transcribe: jest.fn(), cancel: jest.fn(), - validateApiKey: jest.fn() + validateApiKey: jest.fn(), } as jest.Mocked; mockAudioProcessor = { validate: jest.fn(), process: jest.fn(), - extractMetadata: jest.fn() + extractMetadata: jest.fn(), } as jest.Mocked; mockTextFormatter = { format: jest.fn(), insertTimestamps: jest.fn(), - cleanUp: jest.fn() + cleanUp: jest.fn(), } as jest.Mocked; mockEventManager = { emit: jest.fn(), on: jest.fn(), off: jest.fn(), - once: jest.fn() + once: jest.fn(), } as jest.Mocked; mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), - error: jest.fn() + error: jest.fn(), }; transcriptionService = new TranscriptionService( @@ -103,16 +103,21 @@ describe('TranscriptionService', () => { expect(mockTextFormatter.format).toHaveBeenCalledWith(mockWhisperResponse.text); // Verify events - expect(mockEventManager.emit).toHaveBeenCalledWith('transcription:start', { fileName: file.name }); - expect(mockEventManager.emit).toHaveBeenCalledWith('transcription:complete', expect.objectContaining({ result })); + expect(mockEventManager.emit).toHaveBeenCalledWith('transcription:start', { + fileName: file.name, + }); + expect(mockEventManager.emit).toHaveBeenCalledWith( + 'transcription:complete', + expect.objectContaining({ result }) + ); }); it('should handle validation failure', async () => { const file = createMockAudioFile(); const validationErrors = ['File too large', 'Unsupported format']; - const mockValidation = createMockValidationResult({ - valid: false, - errors: validationErrors + const mockValidation = createMockValidationResult({ + valid: false, + errors: validationErrors, }); mockAudioProcessor.validate.mockResolvedValue(mockValidation); @@ -123,14 +128,19 @@ describe('TranscriptionService', () => { expect(mockAudioProcessor.process).not.toHaveBeenCalled(); expect(mockWhisperService.transcribe).not.toHaveBeenCalled(); - expect(mockEventManager.emit).toHaveBeenCalledWith('transcription:error', expect.any(Object)); + expect(mockEventManager.emit).toHaveBeenCalledWith( + 'transcription:error', + expect.any(Object) + ); }); it('should handle audio processing failure', async () => { const file = createMockAudioFile(); const error = new Error('Failed to read audio file'); - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockRejectedValue(error); await expect(transcriptionService.transcribe(file)).rejects.toThrow(error); @@ -143,7 +153,9 @@ describe('TranscriptionService', () => { const file = createMockAudioFile(); const error = new Error('API request failed'); - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockResolvedValue(createMockProcessedAudio()); mockWhisperService.transcribe.mockRejectedValue(error); @@ -157,10 +169,14 @@ describe('TranscriptionService', () => { const file = createMockAudioFile(); const error = new Error('Formatting failed'); - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockResolvedValue(createMockProcessedAudio()); mockWhisperService.transcribe.mockResolvedValue(createMockWhisperResponse()); - mockTextFormatter.format.mockImplementation(() => { throw error; }); + mockTextFormatter.format.mockImplementation(() => { + throw error; + }); await expect(transcriptionService.transcribe(file)).rejects.toThrow(error); @@ -171,11 +187,13 @@ describe('TranscriptionService', () => { const file = createMockAudioFile(); const segments = [ { start: 0, end: 5, text: '첫 번째 세그먼트' }, - { start: 5, end: 10, text: '두 번째 세그먼트' } + { start: 5, end: 10, text: '두 번째 세그먼트' }, ]; const mockWhisperResponse = createMockWhisperResponse({ segments }); - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockResolvedValue(createMockProcessedAudio()); mockWhisperService.transcribe.mockResolvedValue(mockWhisperResponse); mockTextFormatter.format.mockReturnValue('포맷된 텍스트'); @@ -188,7 +206,7 @@ describe('TranscriptionService', () => { id: 0, start: 0, end: 5, - text: '첫 번째 세그먼트' + text: '첫 번째 세그먼트', }); }); @@ -196,7 +214,9 @@ describe('TranscriptionService', () => { const file = createMockAudioFile(); const mockWhisperResponse = createMockWhisperResponse({ segments: undefined }); - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockResolvedValue(createMockProcessedAudio()); mockWhisperService.transcribe.mockResolvedValue(mockWhisperResponse); mockTextFormatter.format.mockReturnValue('포맷된 텍스트'); @@ -294,57 +314,56 @@ describe('TranscriptionService', () => { describe('event emissions', () => { it('should emit start event with file name', async () => { const file = createMockAudioFile({ name: 'test-audio.mp3' }); - - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockResolvedValue(createMockProcessedAudio()); mockWhisperService.transcribe.mockResolvedValue(createMockWhisperResponse()); mockTextFormatter.format.mockReturnValue('텍스트'); await transcriptionService.transcribe(file); - expect(mockEventManager.emit).toHaveBeenCalledWith( - 'transcription:start', - { fileName: 'test-audio.mp3' } - ); + expect(mockEventManager.emit).toHaveBeenCalledWith('transcription:start', { + fileName: 'test-audio.mp3', + }); }); it('should emit complete event with result', async () => { const file = createMockAudioFile(); - - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockResolvedValue(createMockProcessedAudio()); mockWhisperService.transcribe.mockResolvedValue(createMockWhisperResponse()); mockTextFormatter.format.mockReturnValue('완료된 텍스트'); const result = await transcriptionService.transcribe(file); - expect(mockEventManager.emit).toHaveBeenCalledWith( - 'transcription:complete', - { result } - ); + expect(mockEventManager.emit).toHaveBeenCalledWith('transcription:complete', { + result, + }); }); it('should emit error event on any failure', async () => { const file = createMockAudioFile(); const error = new Error('Test error'); - + mockAudioProcessor.validate.mockRejectedValue(error); await expect(transcriptionService.transcribe(file)).rejects.toThrow(error); - expect(mockEventManager.emit).toHaveBeenCalledWith( - 'transcription:error', - { error } - ); + expect(mockEventManager.emit).toHaveBeenCalledWith('transcription:error', { error }); }); }); describe('edge cases', () => { it('should handle empty validation errors array', async () => { const file = createMockAudioFile(); - const mockValidation = createMockValidationResult({ - valid: false, - errors: [] + const mockValidation = createMockValidationResult({ + valid: false, + errors: [], }); mockAudioProcessor.validate.mockResolvedValue(mockValidation); @@ -356,9 +375,9 @@ describe('TranscriptionService', () => { it('should handle undefined validation errors', async () => { const file = createMockAudioFile(); - const mockValidation = createMockValidationResult({ - valid: false, - errors: undefined + const mockValidation = createMockValidationResult({ + valid: false, + errors: undefined, }); mockAudioProcessor.validate.mockResolvedValue(mockValidation); @@ -372,7 +391,9 @@ describe('TranscriptionService', () => { const file = createMockAudioFile(); const mockWhisperResponse = createMockWhisperResponse({ text: '' }); - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockResolvedValue(createMockProcessedAudio()); mockWhisperService.transcribe.mockResolvedValue(mockWhisperResponse); mockTextFormatter.format.mockReturnValue(''); @@ -387,7 +408,9 @@ describe('TranscriptionService', () => { const longText = '긴 텍스트 '.repeat(10000); const mockWhisperResponse = createMockWhisperResponse({ text: longText }); - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockResolvedValue(createMockProcessedAudio()); mockWhisperService.transcribe.mockResolvedValue(mockWhisperResponse); mockTextFormatter.format.mockReturnValue(longText); @@ -404,16 +427,18 @@ describe('TranscriptionService', () => { const files = [ createMockAudioFile({ name: 'file1.mp3' }), createMockAudioFile({ name: 'file2.mp3' }), - createMockAudioFile({ name: 'file3.mp3' }) + createMockAudioFile({ name: 'file3.mp3' }), ]; - mockAudioProcessor.validate.mockResolvedValue(createMockValidationResult({ valid: true })); + mockAudioProcessor.validate.mockResolvedValue( + createMockValidationResult({ valid: true }) + ); mockAudioProcessor.process.mockResolvedValue(createMockProcessedAudio()); mockWhisperService.transcribe.mockResolvedValue(createMockWhisperResponse()); mockTextFormatter.format.mockReturnValue('텍스트'); const results = await Promise.all( - files.map(file => transcriptionService.transcribe(file)) + files.map((file) => transcriptionService.transcribe(file)) ); expect(results).toHaveLength(3); @@ -421,4 +446,4 @@ describe('TranscriptionService', () => { expect(mockWhisperService.transcribe).toHaveBeenCalledTimes(3); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/WhisperService.test.ts b/tests/unit/WhisperService.test.ts index dfccfd3..581ae1f 100644 --- a/tests/unit/WhisperService.test.ts +++ b/tests/unit/WhisperService.test.ts @@ -3,42 +3,42 @@ */ import { requestUrl } from 'obsidian'; -import { - WhisperService, +import { + WhisperService, WhisperAPIError, AuthenticationError, RateLimitError, FileTooLargeError, - ServerError + ServerError, } from '../../src/infrastructure/api/WhisperService'; import type { ILogger, WhisperOptions } from '../../src/types'; import { createMockArrayBuffer, createMockWhisperResponse, - createMockAPIErrorResponse + createMockAPIErrorResponse, } from '../helpers/mockDataFactory'; import '../helpers/testSetup'; // Mock Obsidian's requestUrl jest.mock('obsidian', () => ({ - requestUrl: jest.fn() + requestUrl: jest.fn(), })); // Mock helper functions jest.mock('../../src/utils/common/helpers', () => ({ retry: jest.fn((fn) => fn()), sleep: jest.fn(() => Promise.resolve()), - withTimeout: jest.fn((promise) => promise) + withTimeout: jest.fn((promise) => promise), })); jest.mock('../../src/utils/common/validators', () => ({ validateApiKey: jest.fn(() => ({ valid: true })), - validateRange: jest.fn((value) => ({ valid: value >= 0 && value <= 1, value })) + validateRange: jest.fn((value) => ({ valid: value >= 0 && value <= 1, value })), })); jest.mock('../../src/utils/common/formatters', () => ({ formatFileSize: jest.fn((size) => `${(size / 1024 / 1024).toFixed(2)}MB`), - truncateText: jest.fn((text, maxLength) => text.substring(0, maxLength)) + truncateText: jest.fn((text, maxLength) => text.substring(0, maxLength)), })); describe('WhisperService', () => { @@ -51,7 +51,7 @@ describe('WhisperService', () => { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), - error: jest.fn() + error: jest.fn(), }; whisperService = new WhisperService(mockApiKey, mockLogger); @@ -62,10 +62,10 @@ describe('WhisperService', () => { it('should successfully transcribe audio', async () => { const audioBuffer = createMockArrayBuffer(1024); const mockResponse = createMockWhisperResponse(); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: mockResponse + json: mockResponse, }); const result = await whisperService.transcribe(audioBuffer); @@ -77,8 +77,8 @@ describe('WhisperService', () => { url: 'https://api.openai.com/v1/audio/transcriptions', method: 'POST', headers: expect.objectContaining({ - Authorization: `Bearer ${mockApiKey}` - }) + Authorization: `Bearer ${mockApiKey}`, + }), }) ); }); @@ -90,19 +90,19 @@ describe('WhisperService', () => { model: 'whisper-1', temperature: 0.2, prompt: 'Test prompt', - responseFormat: 'verbose_json' + responseFormat: 'verbose_json', }; (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); await whisperService.transcribe(audioBuffer, options); const callArgs = (requestUrl as jest.Mock).mock.calls[0][0]; const formData = callArgs.body as FormData; - + // Verify FormData append was called with correct parameters expect(formData.append).toHaveBeenCalledWith('model', 'whisper-1'); expect(formData.append).toHaveBeenCalledWith('language', 'ko'); @@ -120,14 +120,14 @@ describe('WhisperService', () => { it('should handle text response format', async () => { const audioBuffer = createMockArrayBuffer(1024); const textResponse = 'Transcribed text only'; - + (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: textResponse + json: textResponse, }); const result = await whisperService.transcribe(audioBuffer, { - responseFormat: 'text' + responseFormat: 'text', }); expect(result.text).toBe(textResponse); @@ -142,17 +142,17 @@ describe('WhisperService', () => { duration: 10.5, segments: [ { start: 0, end: 5, text: 'First segment' }, - { start: 5, end: 10.5, text: 'Second segment' } - ] + { start: 5, end: 10.5, text: 'Second segment' }, + ], }; - + (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: mockResponse + json: mockResponse, }); const result = await whisperService.transcribe(audioBuffer, { - responseFormat: 'verbose_json' + responseFormat: 'verbose_json', }); expect(result.text).toBe(mockResponse.text); @@ -162,34 +162,34 @@ describe('WhisperService', () => { it('should skip language parameter when set to auto', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); await whisperService.transcribe(audioBuffer, { - language: 'auto' + language: 'auto', }); const callArgs = (requestUrl as jest.Mock).mock.calls[0][0]; const formData = callArgs.body as FormData; - + // Should not append language when set to 'auto' expect(formData.append).not.toHaveBeenCalledWith('language', 'auto'); }); it('should validate and clamp temperature value', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); // Test invalid temperature (> 1) await whisperService.transcribe(audioBuffer, { - temperature: 1.5 + temperature: 1.5, }); expect(mockLogger.warn).toHaveBeenCalledWith( @@ -201,19 +201,19 @@ describe('WhisperService', () => { it('should truncate long prompts', async () => { const audioBuffer = createMockArrayBuffer(1024); const longPrompt = 'Very long prompt '.repeat(100); // > 224 tokens - + (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); await whisperService.transcribe(audioBuffer, { - prompt: longPrompt + prompt: longPrompt, }); const callArgs = (requestUrl as jest.Mock).mock.calls[0][0]; const formData = callArgs.body as FormData; - + // Verify prompt was truncated expect(formData.append).toHaveBeenCalledWith( 'prompt', @@ -225,22 +225,24 @@ describe('WhisperService', () => { describe('error handling', () => { it('should handle 401 authentication error', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 401, - json: { error: { message: 'Invalid API key' } } + json: { error: { message: 'Invalid API key' } }, }); - await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow(AuthenticationError); + await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow( + AuthenticationError + ); }); it('should handle 429 rate limit error', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 429, json: { error: { message: 'Rate limit exceeded' } }, - headers: { 'retry-after': '60' } + headers: { 'retry-after': '60' }, }); await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow(RateLimitError); @@ -248,10 +250,10 @@ describe('WhisperService', () => { it('should handle 413 file too large error', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 413, - json: { error: { message: 'Request entity too large' } } + json: { error: { message: 'Request entity too large' } }, }); await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow(FileTooLargeError); @@ -259,10 +261,10 @@ describe('WhisperService', () => { it('should handle 500 server error', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 500, - json: { error: { message: 'Internal server error' } } + json: { error: { message: 'Internal server error' } }, }); await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow(ServerError); @@ -270,7 +272,7 @@ describe('WhisperService', () => { it('should handle network errors', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockRejectedValue(new Error('Network error')); await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow('Network error'); @@ -280,23 +282,23 @@ describe('WhisperService', () => { const audioBuffer = createMockArrayBuffer(1024); const abortError = new Error('Aborted'); abortError.name = 'AbortError'; - + (requestUrl as jest.Mock).mockRejectedValue(abortError); await expect(whisperService.transcribe(audioBuffer)).rejects.toThrow( expect.objectContaining({ code: 'CANCELLED', - message: 'Transcription cancelled' + message: 'Transcription cancelled', }) ); }); it('should handle malformed response', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: null + json: null, }); const result = await whisperService.transcribe(audioBuffer); @@ -329,7 +331,7 @@ describe('WhisperService', () => { it('should validate valid API key', async () => { (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: { text: 'Test' } + json: { text: 'Test' }, }); const isValid = await whisperService.validateApiKey('sk-valid-key'); @@ -340,7 +342,7 @@ describe('WhisperService', () => { it('should reject invalid API key', async () => { (requestUrl as jest.Mock).mockResolvedValue({ status: 401, - json: { error: { message: 'Invalid API key' } } + json: { error: { message: 'Invalid API key' } }, }); const isValid = await whisperService.validateApiKey('invalid-key'); @@ -352,7 +354,7 @@ describe('WhisperService', () => { it('should handle non-auth errors during validation', async () => { (requestUrl as jest.Mock).mockResolvedValue({ status: 500, - json: { error: { message: 'Server error' } } + json: { error: { message: 'Server error' } }, }); const isValid = await whisperService.validateApiKey('sk-test-key'); @@ -365,10 +367,10 @@ describe('WhisperService', () => { it('should restore original API key after validation', async () => { const originalKey = mockApiKey; const testKey = 'sk-test-key'; - + (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: { text: 'Test' } + json: { text: 'Test' }, }); await whisperService.validateApiKey(testKey); @@ -391,7 +393,7 @@ describe('WhisperService', () => { const buffers = [ createMockArrayBuffer(1024), createMockArrayBuffer(2048), - createMockArrayBuffer(3072) + createMockArrayBuffer(3072), ]; let callCount = 0; @@ -399,12 +401,12 @@ describe('WhisperService', () => { callCount++; return Promise.resolve({ status: 200, - json: createMockWhisperResponse({ text: `Response ${callCount}` }) + json: createMockWhisperResponse({ text: `Response ${callCount}` }), }); }); const results = await Promise.all( - buffers.map(buffer => whisperService.transcribe(buffer)) + buffers.map((buffer) => whisperService.transcribe(buffer)) ); expect(results).toHaveLength(3); @@ -414,20 +416,17 @@ describe('WhisperService', () => { }); it('should continue queue processing after error', async () => { - const buffers = [ - createMockArrayBuffer(1024), - createMockArrayBuffer(2048) - ]; + const buffers = [createMockArrayBuffer(1024), createMockArrayBuffer(2048)]; (requestUrl as jest.Mock) .mockRejectedValueOnce(new Error('First request failed')) .mockResolvedValueOnce({ status: 200, - json: createMockWhisperResponse({ text: 'Second request success' }) + json: createMockWhisperResponse({ text: 'Second request success' }), }); const results = await Promise.allSettled( - buffers.map(buffer => whisperService.transcribe(buffer)) + buffers.map((buffer) => whisperService.transcribe(buffer)) ); expect(results[0].status).toBe('rejected'); @@ -441,9 +440,9 @@ describe('WhisperService', () => { describe('performance', () => { it('should handle timeout correctly', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockImplementation( - () => new Promise(resolve => setTimeout(resolve, 60000)) + () => new Promise((resolve) => setTimeout(resolve, 60000)) ); // This should timeout (30s timeout configured) @@ -456,10 +455,10 @@ describe('WhisperService', () => { it('should log processing time', async () => { const audioBuffer = createMockArrayBuffer(1024); - + (requestUrl as jest.Mock).mockResolvedValue({ status: 200, - json: createMockWhisperResponse() + json: createMockWhisperResponse(), }); await whisperService.transcribe(audioBuffer); @@ -470,4 +469,4 @@ describe('WhisperService', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/bugfixes.test.ts b/tests/unit/bugfixes.test.ts index 0bcea0e..a14e99a 100644 --- a/tests/unit/bugfixes.test.ts +++ b/tests/unit/bugfixes.test.ts @@ -1,6 +1,6 @@ /** * Phase 4 - Task 4.4 버그 수정 회귀 테스트 - * + * * 수정된 버그들이 다시 발생하지 않도록 보장하는 테스트 모음 */ @@ -30,11 +30,11 @@ describe('Critical Bug Fixes - 플러그인 크래시 방지', () => { const path = require('path'); const tsconfigPath = path.resolve(__dirname, '../../tsconfig.json'); const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf8')); - + // sourceMap과 inlineSourceMap이 동시에 설정되지 않았는지 확인 const hasSourceMap = tsconfig.compilerOptions.sourceMap === true; const hasInlineSourceMap = tsconfig.compilerOptions.inlineSourceMap === true; - + expect(hasSourceMap && hasInlineSourceMap).toBe(false); }); }); @@ -50,17 +50,16 @@ describe('Critical Bug Fixes - 플러그인 크래시 방지', () => { it('should use valid test audio size for API key validation', async () => { // validateApiKey 메서드가 1KB 크기의 테스트 오디오를 사용하는지 확인 - const performTranscriptionSpy = jest.spyOn( - whisperService as any, - 'performTranscription' - ).mockResolvedValue({ text: 'test' }); + const performTranscriptionSpy = jest + .spyOn(whisperService as any, 'performTranscription') + .mockResolvedValue({ text: 'test' }); await whisperService.validateApiKey('test-key'); expect(performTranscriptionSpy).toHaveBeenCalled(); const callArgs = performTranscriptionSpy.mock.calls[0]; const testAudioBuffer = callArgs[0] as ArrayBuffer; - + // 테스트 오디오가 1KB (1024 bytes)인지 확인 expect(testAudioBuffer.byteLength).toBe(1024); }); @@ -84,7 +83,7 @@ describe('High Priority Bug Fixes - 기능 동작 실패', () => { stat: { size: 1000 }, extension: 'mp3', name: 'test.mp3', - path: 'test.mp3' + path: 'test.mp3', }; // Mock readBinary @@ -107,14 +106,12 @@ describe('High Priority Bug Fixes - 기능 동작 실패', () => { stat: { size: 30 * 1024 * 1024 }, // 30MB - 제한 초과 extension: 'mp3', name: 'test.mp3', - path: 'test.mp3' + path: 'test.mp3', }; const cleanupSpy = jest.spyOn(fileUploadManager, 'cleanup'); - await expect( - fileUploadManager.prepareAudioFile(mockFile as any) - ).rejects.toThrow(); + await expect(fileUploadManager.prepareAudioFile(mockFile as any)).rejects.toThrow(); // 에러가 발생해도 cleanup이 호출되어야 함 expect(cleanupSpy).toHaveBeenCalled(); @@ -130,7 +127,7 @@ describe('Medium Priority Bug Fixes - UX 개선', () => { jest.useFakeTimers(); notificationManager = new NotificationManager({ defaultDuration: 5000, - soundEnabled: false + soundEnabled: false, }); }); @@ -142,7 +139,7 @@ describe('Medium Priority Bug Fixes - UX 개선', () => { const message = 'Test notification'; const options = { type: 'info' as const, - message + message, }; // 첫 번째 알림 @@ -163,11 +160,11 @@ describe('Medium Priority Bug Fixes - UX 개선', () => { it('should allow different messages immediately', () => { const options1 = { type: 'info' as const, - message: 'Message 1' + message: 'Message 1', }; const options2 = { type: 'info' as const, - message: 'Message 2' + message: 'Message 2', }; const id1 = notificationManager.show(options1); @@ -180,15 +177,15 @@ describe('Medium Priority Bug Fixes - UX 개선', () => { it('should differentiate by notification type', () => { const message = 'Same message'; - + const id1 = notificationManager.show({ type: 'info', - message + message, }); - + const id2 = notificationManager.show({ type: 'error', - message + message, }); expect(id1).toBeTruthy(); @@ -219,12 +216,12 @@ describe('Performance and Memory Tests', () => { it('should not accumulate notification messages in memory', () => { const notificationManager = new NotificationManager(); - + // 100개의 알림 생성 for (let i = 0; i < 100; i++) { notificationManager.show({ type: 'info', - message: `Message ${i}` + message: `Message ${i}`, }); jest.advanceTimersByTime(2100); // 각 알림 사이 2.1초 경과 } @@ -233,9 +230,9 @@ describe('Performance and Memory Tests', () => { // (private 멤버이므로 간접적으로 확인) const duplicateId = notificationManager.show({ type: 'info', - message: 'Message 0' // 첫 번째 메시지와 동일 + message: 'Message 0', // 첫 번째 메시지와 동일 }); - + // 오래 전 메시지이므로 중복으로 처리되지 않아야 함 expect(duplicateId).not.toBe(''); }); @@ -249,9 +246,7 @@ describe('Integration Tests', () => { // Mock fetch to simulate API error global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); - await expect( - whisperService.transcribe(new ArrayBuffer(1000)) - ).rejects.toThrow(); + await expect(whisperService.transcribe(new ArrayBuffer(1000))).rejects.toThrow(); // 플러그인이 크래시하지 않고 에러를 처리했는지 확인 expect(mockLogger.error).toHaveBeenCalled(); diff --git a/tests/unit/deepgram-integration.test.ts b/tests/unit/deepgram-integration.test.ts index e1242ea..e3d096c 100644 --- a/tests/unit/deepgram-integration.test.ts +++ b/tests/unit/deepgram-integration.test.ts @@ -17,7 +17,7 @@ describe('Deepgram Integration Tests', () => { model: 'base', experimentalFeatures: { enableStreaming: false, - enableSpeakerDiarization: false + enableSpeakerDiarization: false, }, providerSettings: { deepgram: { @@ -26,21 +26,21 @@ describe('Deepgram Integration Tests', () => { features: { punctuate: true, utterances: false, - smartFormat: true - } + smartFormat: true, + }, }, whisper: { model: 'whisper-1', - temperature: 0 - } + temperature: 0, + }, }, abTesting: { enabled: false, whisperPercentage: 50, - forceProvider: null - } + forceProvider: null, + }, } as PluginSettings; - + factory = new TranscriberFactory(mockSettings, console); }); @@ -90,7 +90,7 @@ describe('Deepgram Integration Tests', () => { test('should handle missing API key gracefully', async () => { mockSettings.deepgramApiKey = ''; mockSettings.transcriptionProvider = 'deepgram'; - + expect(() => factory.getProvider()).toThrow(/API key/i); }); @@ -107,16 +107,16 @@ describe('Deepgram Integration Tests', () => { mockSettings.abTesting = { enabled: true, whisperPercentage: 100, - forceProvider: null + forceProvider: null, }; - + const providers = []; for (let i = 0; i < 10; i++) { providers.push(factory.getProvider()); } - + // With 100% whisper, all should be WhisperAdapter - const allWhisper = providers.every(p => p instanceof WhisperAdapter); + const allWhisper = providers.every((p) => p instanceof WhisperAdapter); expect(allWhisper).toBe(true); }); @@ -124,9 +124,9 @@ describe('Deepgram Integration Tests', () => { mockSettings.abTesting = { enabled: true, whisperPercentage: 0, - forceProvider: 'deepgram' + forceProvider: 'deepgram', }; - + const provider = factory.getProvider(); expect(provider).toBeInstanceOf(DeepgramAdapter); }); @@ -150,21 +150,25 @@ describe('Deepgram Integration Tests', () => { test('should normalize Deepgram response to common format', () => { const deepgramResponse = { results: { - channels: [{ - alternatives: [{ - transcript: '테스트 텍스트', - confidence: 0.95, - words: [ - { word: '테스트', start: 0, end: 0.5 }, - { word: '텍스트', start: 0.5, end: 1.0 } - ] - }] - }] + channels: [ + { + alternatives: [ + { + transcript: '테스트 텍스트', + confidence: 0.95, + words: [ + { word: '테스트', start: 0, end: 0.5 }, + { word: '텍스트', start: 0.5, end: 1.0 }, + ], + }, + ], + }, + ], }, metadata: { duration: 1.0, - channels: 1 - } + channels: 1, + }, }; const adapter = new DeepgramAdapter( @@ -172,9 +176,9 @@ describe('Deepgram Integration Tests', () => { mockSettings, console ); - + // This would be tested with actual normalization logic expect(adapter).toBeDefined(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/deepgramAdapter.test.ts b/tests/unit/deepgramAdapter.test.ts index c7e9871..5c9d68a 100644 --- a/tests/unit/deepgramAdapter.test.ts +++ b/tests/unit/deepgramAdapter.test.ts @@ -2,7 +2,7 @@ import { DeepgramAdapter } from '../../src/infrastructure/api/providers/deepgram import { TranscriptionError, type DeepgramSpecificOptions, - type TranscriptionOptions + type TranscriptionOptions, } from '../../src/infrastructure/api/providers/ITranscriber'; import type { DeepgramService } from '../../src/infrastructure/api/providers/deepgram/DeepgramService'; import type { ILogger, ISettingsManager } from '../../src/types'; @@ -11,26 +11,31 @@ const createLogger = (): ILogger => ({ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), - error: jest.fn() + error: jest.fn(), }); const createSettingsManager = (overrides?: Record): ISettingsManager => ({ load: jest.fn(), save: jest.fn(), get: jest.fn((key: string) => overrides?.[key]), - set: jest.fn() + set: jest.fn(), }); -const createService = (): DeepgramService => ({ - transcribe: jest.fn(), - parseResponse: jest.fn(), - validateApiKey: jest.fn(), - cancel: jest.fn() -}) as unknown as DeepgramService; +const createService = (): DeepgramService => + ({ + transcribe: jest.fn(), + parseResponse: jest.fn(), + validateApiKey: jest.fn(), + cancel: jest.fn(), + } as unknown as DeepgramService); const createAdapter = (settingsOverrides?: Record) => { const service = createService(); - const adapter = new DeepgramAdapter(service, createLogger(), createSettingsManager(settingsOverrides)); + const adapter = new DeepgramAdapter( + service, + createLogger(), + createSettingsManager(settingsOverrides) + ); return { adapter, service }; }; @@ -48,10 +53,10 @@ describe('DeepgramAdapter', () => { smartFormat: false, diarization: true, utterances: false, - numerals: true - } - } - } + numerals: true, + }, + }, + }, }); const options = ( diff --git a/tsconfig.json b/tsconfig.json index c801da4..689d4d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,59 +1,50 @@ { - "compilerOptions": { - "baseUrl": ".", - "inlineSourceMap": true, - "inlineSources": true, - "module": "ESNext", - "target": "ES6", - "allowJs": true, - "noImplicitAny": true, - "moduleResolution": "node", - "importHelpers": true, - "isolatedModules": true, - "strictNullChecks": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "esModuleInterop": true, - "skipLibCheck": true, - "lib": [ - "DOM", - "ES5", - "ES6", - "ES7", - "ES2015", - "ES2016", - "ES2017", - "ES2018", - "ES2019", - "ES2020", - "ES2021", - "ES2022" - ], - "paths": { - "src/*": ["src/*"], - "@core/*": ["src/core/*"], - "@infrastructure/*": ["src/infrastructure/*"], - "@presentation/*": ["src/presentation/*"], - "@application/*": ["src/application/*"], - "@domain/*": ["src/domain/*"], - "@utils/*": ["src/utils/*"], - "@types/*": ["src/types/*"] + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7", + "ES2015", + "ES2016", + "ES2017", + "ES2018", + "ES2019", + "ES2020", + "ES2021", + "ES2022" + ], + "paths": { + "src/*": ["src/*"], + "@core/*": ["src/core/*"], + "@infrastructure/*": ["src/infrastructure/*"], + "@presentation/*": ["src/presentation/*"], + "@application/*": ["src/application/*"], + "@domain/*": ["src/domain/*"], + "@utils/*": ["src/utils/*"], + "@types/*": ["src/types/*"] + }, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "declaration": false, + "declarationMap": false }, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "experimentalDecorators": true, - "declaration": false, - "declarationMap": false - }, - "include": [ - "src/**/*.ts", - "src/**/*.tsx", - "config/**/*.json" - ], - "exclude": [ - "node_modules", - "main.js", - "**/*.spec.ts", - "**/*.test.ts" - ] -} \ No newline at end of file + "include": ["src/**/*.ts", "src/**/*.tsx", "config/**/*.json"], + "exclude": ["node_modules", "main.js", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/version-bump.mjs b/version-bump.mjs index f3af67c..f7b140c 100644 --- a/version-bump.mjs +++ b/version-bump.mjs @@ -1,16 +1,16 @@ -import { readFileSync, writeFileSync } from "fs"; +import { readFileSync, writeFileSync } from 'fs'; const targetVersion = process.env.npm_package_version; // Update manifest.json -let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); +let manifest = JSON.parse(readFileSync('manifest.json', 'utf8')); const { minAppVersion } = manifest; manifest.version = targetVersion; -writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); +writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t')); // Update versions.json -let versions = JSON.parse(readFileSync("versions.json", "utf8")); +let versions = JSON.parse(readFileSync('versions.json', 'utf8')); versions[targetVersion] = minAppVersion; -writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); +writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); -console.log(`Updated version to ${targetVersion}`); \ No newline at end of file +console.log(`Updated version to ${targetVersion}`); diff --git a/versions.json b/versions.json index 5df4a9a..f962e12 100644 --- a/versions.json +++ b/versions.json @@ -1,16 +1,16 @@ { - "1.0.0": "0.15.0", - "2.0.0": "0.15.0", - "3.0.0": "0.15.0", - "3.0.1": "0.15.0", - "3.0.2": "0.15.0", - "3.0.3": "0.15.0", - "3.0.4": "0.15.0", - "3.0.5": "0.15.0", - "3.0.6": "0.15.0", - "3.0.7": "0.15.0", - "3.0.8": "0.15.0", - "3.0.9": "0.15.0", - "3.0.10": "0.15.0", - "3.0.11": "0.15.0" -} \ No newline at end of file + "1.0.0": "0.15.0", + "2.0.0": "0.15.0", + "3.0.0": "0.15.0", + "3.0.1": "0.15.0", + "3.0.2": "0.15.0", + "3.0.3": "0.15.0", + "3.0.4": "0.15.0", + "3.0.5": "0.15.0", + "3.0.6": "0.15.0", + "3.0.7": "0.15.0", + "3.0.8": "0.15.0", + "3.0.9": "0.15.0", + "3.0.10": "0.15.0", + "3.0.11": "0.15.0" +}