Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ docs/MODULE_COMPLETION_REPORT.md
src/routeTree.gen.ts
.reports/
tanstack-start/

# Test outputs
coverage/
test-results/
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"imports": {
"#/*": "./src/*"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
Expand Down Expand Up @@ -86,16 +89,20 @@
},
"devDependencies": {
"@base-ui/react": "^1.4.1",
"@playwright/test": "^1.60.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.7",
"babel-plugin-react-compiler": "^1.0.0",
"class-variance-authority": "^0.7.1",
"dotenv-cli": "^11.0.0",
Expand Down
38 changes: 38 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
})
280 changes: 259 additions & 21 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

150 changes: 150 additions & 0 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { PrismaClient } from '../src/generated/prisma/client.js'
import { PrismaPg } from '@prisma/adapter-pg'

// Simple ID generator
function generateId(): string {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
}

const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
})

const prisma = new PrismaClient({ adapter })

// Test user credentials (consistent for E2E tests)
export const TEST_USER = {
email: 'test-user@cedium.test',
password: 'TestPassword123!',
name: 'Test User',
}

export const TEST_AUTHOR = {
email: 'test-author@cedium.test',
password: 'AuthorPassword123!',
name: 'Test Author',
}

async function main() {
console.log('🌱 Seeding test data...')

// Delete test users (cascade will clean all related data)
try {
await prisma.user.deleteMany({
where: {
email: { in: [TEST_USER.email, TEST_AUTHOR.email] }
}
})
console.log(' Cleaned existing test data')
} catch {
// Ignore if users don't exist
}

// Create test users
const testUser = await prisma.user.create({
data: {
id: generateId(),
email: TEST_USER.email,
name: TEST_USER.name,
emailVerified: true,
accounts: {
create: {
id: generateId(),
accountId: TEST_USER.email,
providerId: 'credential',
password: TEST_USER.password,
}
}
}
})
console.log(` Created user: ${TEST_USER.email}`)

const testAuthor = await prisma.user.create({
data: {
id: generateId(),
email: TEST_AUTHOR.email,
name: TEST_AUTHOR.name,
emailVerified: true,
bio: 'Test author for E2E testing',
accounts: {
create: {
id: generateId(),
accountId: TEST_AUTHOR.email,
providerId: 'credential',
password: TEST_AUTHOR.password,
}
}
}
})
console.log(` Created user: ${TEST_AUTHOR.email}`)

// Create tags
const techTag = await prisma.tag.create({
data: { name: '技术', slug: 'tech-e2e' }
})
const lifeTag = await prisma.tag.create({
data: { name: '生活', slug: 'life-e2e' }
})

console.log(' Created tags: 技术, 生活')

// Create published articles
const article1 = await prisma.article.create({
data: {
title: '测试文章 - E2E Testing',
slug: 'test-article-e2e-testing',
excerpt: '这是一篇用于 E2E 测试的文章',
content: '# 测试文章\n\n这是测试内容。\n\n## 功能\n\n- 点赞\n- 收藏\n- 评论',
status: 'PUBLISHED',
publishedAt: new Date(),
authorId: testAuthor.id,
likeCount: 5,
bookmarkCount: 2,
commentCount: 1,
}
})
await prisma.articleTag.create({
data: { articleId: article1.id, tagId: techTag.id }
})

// Create second article with life tag
const article2 = await prisma.article.create({
data: {
title: '生活随笔 - Life Notes',
slug: 'test-article-life-notes',
excerpt: '这是一篇关于生活的测试文章',
content: '# 生活随笔\n\n记录生活的点滴。\n\n## 主题\n\n- 日常\n- 思考\n- 感悟',
status: 'PUBLISHED',
publishedAt: new Date(),
authorId: testAuthor.id,
likeCount: 3,
bookmarkCount: 1,
commentCount: 0,
}
})
await prisma.articleTag.create({
data: { articleId: article2.id, tagId: lifeTag.id }
})
console.log(' Created 2 test articles')

// Create comment
await prisma.comment.create({
data: {
content: '这是一条测试评论',
articleId: article1.id,
userId: testUser.id,
likeCount: 2,
}
})

console.log('✅ Test data seeded!')
}

main()
.catch((e) => {
console.error('❌ Seed failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
105 changes: 105 additions & 0 deletions src/hooks/use-debounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, test, expect, vi, afterEach } from 'vitest'
import { renderHook, act } from '@/test/utils/test-utils'
import { useDebounce } from './use-debounce'

describe('useDebounce', () => {
afterEach(() => {
vi.useRealTimers()
})

test('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('test', 500))
expect(result.current).toBe('test')
})

test('debounces value changes', () => {
vi.useFakeTimers()

const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'initial', delay: 500 } }
)

expect(result.current).toBe('initial')

rerender({ value: 'changed', delay: 500 })
expect(result.current).toBe('initial')

act(() => {
vi.advanceTimersByTime(500)
})

expect(result.current).toBe('changed')
})

test('cancels previous timeout on rapid changes', () => {
vi.useFakeTimers()

const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'a' } }
)

rerender({ value: 'b' })
act(() => {
vi.advanceTimersByTime(250)
})

rerender({ value: 'c' })
act(() => {
vi.advanceTimersByTime(500)
})

expect(result.current).toBe('c')
})

test('updates delay dynamically', () => {
vi.useFakeTimers()

const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'test', delay: 500 } }
)

rerender({ value: 'changed', delay: 100 })
act(() => {
vi.advanceTimersByTime(100)
})

expect(result.current).toBe('changed')
})

test('works with numbers', () => {
vi.useFakeTimers()

const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 0 } }
)

expect(result.current).toBe(0)

rerender({ value: 42 })
act(() => {
vi.advanceTimersByTime(300)
})

expect(result.current).toBe(42)
})

test('works with objects', () => {
vi.useFakeTimers()

const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: { name: 'initial' } } }
)

rerender({ value: { name: 'updated' } })
act(() => {
vi.advanceTimersByTime(300)
})

expect(result.current.name).toBe('updated')
})
})
Loading