Comprehensive Refactoring & Type Safety Fixes (#48)

* refactor: address PR #8004 review comments and fix lint errors

* fix: address CI failures and code review feedback

* Refactor: comprehensive fix for lint errors and type safety regressions

* chore: trigger CI and Claude Code Review workflows
This commit is contained in:
asyouplz 2026-01-20 10:41:22 +09:00 committed by GitHub
parent 0e5f9de3a0
commit fc3ac57026
67 changed files with 3493 additions and 3069 deletions

View file

@ -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"
}
}
}

View file

@ -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!"

View file

@ -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:*)'

View file

@ -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<<EOF'
cat release-notes.md
echo 'EOF'
} >> $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<<EOF'
cat release-notes.md
echo 'EOF'
} >> $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

View file

@ -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

View file

@ -14,4 +14,4 @@
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css",
"embeddedLanguageFormatting": "auto"
}
}

View file

@ -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

View file

@ -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! 🎉
Thank you for contributing! 🎉

746
README.md

File diff suppressed because it is too large Load diff

View file

@ -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
}
}
}
}

View file

@ -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)

View file

@ -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/)

View file

@ -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" 라벨로 등록해 주세요.

View file

@ -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를 이용해 주세요.

View file

@ -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/)

View file

@ -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

View file

@ -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에 재현 단계와 기대/실제 결과를 작성합니다.

View file

@ -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();
}
}

View file

@ -8,9 +8,9 @@ const tsJestTransform = [
tsconfig: {
esModuleInterop: true,
allowSyntheticDefaultImports: true,
skipLibCheck: true
}
}
skipLibCheck: true,
},
},
];
const moduleNameMapper = {
@ -24,17 +24,17 @@ const moduleNameMapper = {
'^@types/(.*)$': '<rootDir>/src/types/$1',
'\\.(css|less|scss|sass)$': '<rootDir>/tests/mocks/styleMock.js',
'\\.(jpg|jpeg|png|gif|svg|webp)$': '<rootDir>/tests/mocks/fileMock.js',
'^obsidian$': '<rootDir>/tests/mocks/obsidian.mock.ts'
'^obsidian$': '<rootDir>/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,
},
},
}),
],
};

View file

@ -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
}
"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
}

View file

@ -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"
]
}
}
"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"
]
}
}

View file

@ -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}`);

View file

@ -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();

View file

@ -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';

View file

@ -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 {
// 최종 실패

View file

@ -87,7 +87,7 @@ export class FileUploadManager {
message: 'Validating file...',
});
await this.validateFile(file);
this.validateFile(file);
this.ensureNotCancelled();
// 2. 파일 읽기

View file

@ -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<void> {
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<SettingsSchema>;
// 암호화된 파일 처리
if (options.password) {
importedSettings = await this.encryptor.decryptSensitiveSettings(importedSettings);
importedSettings = (await this.encryptor.decryptSensitiveSettings(
importedSettings as Record<string, unknown>
)) as unknown as Partial<SettingsSchema>;
}
// 검증
@ -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<void> {
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<Uint8Array>;
readable: ReadableStream<Uint8Array>;
};
@ -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<Uint8Array>;
readable: ReadableStream<Uint8Array>;
};

View file

@ -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

View file

@ -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<PluginSettings> {
const { apiKey: _apiKey, encryptedApiKey: _encryptedApiKey, ...exported } = this.settings;
const { apiKey: _, encryptedApiKey: __, ...exported } = this.settings;
return exported;
}
// 설정 가져오기
async importSettings(imported: Partial<PluginSettings>): Promise<void> {
// API 키는 가져오지 않음
const { apiKey: _apiKey, encryptedApiKey: _encryptedApiKey, ...safeImported } = imported;
const { apiKey: _, encryptedApiKey: __, ...safeImported } = imported;
this.settings = { ...this.settings, ...safeImported };
await this.save(this.settings);

View file

@ -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<string, unknown> | null;
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData ?? {});
}
async saveSettings() {

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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();
}

View file

@ -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,

View file

@ -43,12 +43,12 @@ export class ProviderSettingsContainer {
/**
*
*/
private async initialize(): Promise<void> {
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<void> {
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<boolean> {
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' });

View file

@ -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<ProviderSettingsState>;
// 메모이제이션을 위한 캐시
private memoCache = new Map<string, any>();
private memoCache = new Map<string, unknown>();
// 실시간 업데이트 간격
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<void> {
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<void>, 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<void> {
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<void> {
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()} 상세 정보`);

View file

@ -15,4 +15,3 @@
.provider-settings {
display: block;
}

View file

@ -39,7 +39,7 @@ export interface ProviderStatus {
/**
*
*/
export interface ValidationResult<T = any> {
export interface ValidationResult<T = unknown> {
isValid: boolean;
errors: string[];
warnings: string[];
@ -49,7 +49,7 @@ export interface ValidationResult<T = any> {
/**
*
*/
export interface SettingsChangeEvent<T = any> {
export interface SettingsChangeEvent<T = unknown> {
key: string;
oldValue: T;
newValue: T;
@ -75,15 +75,7 @@ export interface MetricsData {
/**
*
*/
export type SettingValue =
| string
| number
| boolean
| Date
| TranscriptionProvider
| SelectionStrategy
| Record<string, unknown>
| unknown[];
export type SettingValue = string | number | boolean | Date | Record<string, unknown> | unknown[];
/**
* (type-safe keys)
@ -186,14 +178,14 @@ export type DeepReadonly<T> = {
* Required Keys
*/
export type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
[K in keyof T]-?: object extends Pick<T, K> ? never : K;
}[keyof T];
/**
* Optional Keys
*/
export type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
[K in keyof T]-?: object extends Pick<T, K> ? K : never;
}[keyof T];
/**
@ -288,8 +280,10 @@ export class SettingsChangeTracker<T extends Record<string, unknown>> {
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<T extends Record<string, unknown>> {
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<T>(json: string, defaultValue: T): T {
/**
*
*/
export function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
export function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
const result = { ...target };
for (const key in source) {
@ -408,9 +405,12 @@ export function deepMerge<T extends Record<string, any>>(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<string, unknown>,
sourceValue as Record<string, unknown>
) as T[Extract<keyof T, string>];
} else {
result[key] = sourceValue as T[typeof key];
result[key] = sourceValue as T[Extract<keyof T, string>];
}
}
}

View file

@ -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 {

View file

@ -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% {

View file

@ -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;

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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 });
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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<HTMLInputElement>;
const formatRadios = containerEl.querySelectorAll(
'input[name="response-format"]'
) as NodeListOf<HTMLInputElement>;
// 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'));

View file

@ -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<void> =>
new Promise(resolve => setTimeout(resolve, ms));
export const waitFor = (ms: number): Promise<void> =>
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);
};

View file

@ -1,6 +1,6 @@
/**
* Global Setup for Tests
*
*
*
* -
* -
@ -13,12 +13,12 @@ import * as path from 'path';
const globalSetup = async (): Promise<void> => {
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<void> => {
'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<void>((resolve) => {
mockServer.stdout?.on('data', (data) => {
@ -67,56 +67,56 @@ const globalSetup = async (): Promise<void> => {
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<void> => {
*/
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<void> {
console.log('Setting up browser environment for E2E tests...');
// Playwright 설치 확인
try {
require('playwright');
@ -162,14 +162,14 @@ async function setupBrowserEnvironment(): Promise<void> {
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<void> {
*
*/
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;
export default globalSetup;

View file

@ -1,6 +1,6 @@
/**
* Global Teardown for Tests
*
*
*
* -
* -
@ -16,7 +16,7 @@ const execAsync = promisify(exec);
const globalTeardown = async (): Promise<void> => {
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<void> => {
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<void> => {
}
});
}
// 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<void> => {
*/
async function aggregateTestResults(): Promise<void> {
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(/<testsuite[^>]*tests="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"[^>]*time="([\d.]+)"/);
const matches = content.match(
/<testsuite[^>]*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<void> {
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<void> {
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<void> {
*/
async function mergeCoverageReports(): Promise<void> {
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<void> {
*/
async function collectFailureLogs(): Promise<void> {
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<void> {
*/
async function sendTestNotifications(): Promise<void> {
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;
export default globalTeardown;

View file

@ -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<Vault>(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> = {}): SpeechToTextSettings {
export function createMockSettings(
overrides: Partial<SpeechToTextSettings> = {}
): SpeechToTextSettings {
return {
apiKey: 'test-api-key',
apiEndpoint: 'https://api.openai.com/v1/audio/transcriptions',
@ -147,14 +155,14 @@ export function createMockSettings(overrides: Partial<SpeechToTextSettings> = {}
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<SpeechToTextSettings> = {}
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<AudioMetadata>;
originalFile?: TFile;
compressed?: boolean;
} = {}): ProcessedAudio {
export function createMockProcessedAudio(
options: {
buffer?: ArrayBuffer;
metadata?: Partial<AudioMetadata>;
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<AudioMetadata> = {}):
sampleRate: 44100,
channels: 2,
codec: 'mp3',
...overrides
...overrides,
};
}
@ -238,7 +250,7 @@ export function createMockFormatOptions(overrides: Partial<FormatOptions> = {}):
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<Vault> {
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;
}

View file

@ -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;
module.exports = CustomSequencer;

View file

@ -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;

View file

@ -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'); // 다른 설정은 변경
});
});
});
});

View file

@ -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();
});
});
});
});

View file

@ -16,7 +16,7 @@ const obsidianMock = {
Notice: fn(),
DropdownComponent: fn(),
ToggleComponent: fn(),
TextComponent: fn()
TextComponent: fn(),
};
export = obsidianMock;

View file

@ -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<any>;
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<any[]>({
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<object>({
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<any[]>({
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();
});
});
});

View file

@ -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');
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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();
});
});
});
});

View file

@ -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
});
});
});
});

View file

@ -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', () => {
}
});
});
});
});

View file

@ -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<IWhisperService>;
mockAudioProcessor = {
validate: jest.fn(),
process: jest.fn(),
extractMetadata: jest.fn()
extractMetadata: jest.fn(),
} as jest.Mocked<IAudioProcessor>;
mockTextFormatter = {
format: jest.fn(),
insertTimestamps: jest.fn(),
cleanUp: jest.fn()
cleanUp: jest.fn(),
} as jest.Mocked<ITextFormatter>;
mockEventManager = {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
once: jest.fn()
once: jest.fn(),
} as jest.Mocked<IEventManager>;
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);
});
});
});
});

View file

@ -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', () => {
);
});
});
});
});

View file

@ -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();

View file

@ -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();
});
});
});
});

View file

@ -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<string, unknown>): 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<string, unknown>) => {
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 = (

View file

@ -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"
]
}
"include": ["src/**/*.ts", "src/**/*.tsx", "config/**/*.json"],
"exclude": ["node_modules", "main.js", "**/*.spec.ts", "**/*.test.ts"]
}

View file

@ -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}`);
console.log(`Updated version to ${targetVersion}`);

View file

@ -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"
}
"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"
}