diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4abadc..34e3996 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: - run: npm run format - run: npm run lint - run: npm run test-ci + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e -- e2e/signin.spec.ts - run: npm run build:collaboration - run: npm run check:cli - run: npm run build diff --git a/.gitignore b/.gitignore index f78f98c..8ed7c45 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # testing /coverage +/playwright-report +/test-results # next.js /.next/ diff --git a/AGENTS.md b/AGENTS.md index 7e1c87a..beed120 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ These instructions apply to the entire repository. ## Product language -Use the lowercase product name `doc`. Keep the visual and written system compatible with -`fullstack-ai-infra/mem`: restrained, technical, dark-first, and explicit about current capability -versus roadmap. +Use the lowercase product name `doc`. Consume the organization-owned semantic tokens and shared +components from `@fullstack-ai-infra/ui`; do not recreate brand primitives or introduce raw product +colors. The approved C direction is warm paper canvas, stone navigation, sage primary actions, and +lavender reserved for AI affordances. Light and dark themes are equally supported, and product copy +must remain explicit about current capability versus roadmap. diff --git a/CHANGELOG.md b/CHANGELOG.md index ab58180..f0e08b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to `doc` are documented here. ### Added +- Adoption of the shared `@fullstack-ai-infra/ui` design system for the first document + workflow: workspace shell (responsive sidebar with compact mode), editor chrome, and + landing/sign-in surfaces consume shared tokens and components; adds a Playwright + end-to-end baseline for the sign-in flow and component coverage for the new shell. +- Shared design system adoption for the first document workflow: the web app consumes + `@fullstack-ai-infra/ui` tokens and components (supplied via the vendored tarball), with a + responsive workspace and compact-mode navigation, accessibility labels, theme bootstrap, + editor legacy fallback and parsed-content helpers, and a Playwright end-to-end baseline for + the sign-in flow. - New `doc` product identity and mem-aligned dark-first design system. - Next.js document product and integrated Yjs/Hocuspocus collaboration service. - Document version history, diff, and restore capability. diff --git a/e2e/signin.spec.ts b/e2e/signin.spec.ts new file mode 100644 index 0000000..1403e0e --- /dev/null +++ b/e2e/signin.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test' + +const providers = { + nodemailer: { + id: 'nodemailer', + name: 'Email', + type: 'email', + signinUrl: '/api/auth/signin/nodemailer', + callbackUrl: '/api/auth/callback/nodemailer', + }, +} + +for (const viewport of [ + { name: 'phone-390', width: 390, height: 844 }, + { name: 'compact-600', width: 600, height: 900 }, +]) { + test(`sign-in is keyboard-accessible in light and dark at ${viewport.width}px`, async ({ page }) => { + await page.setViewportSize(viewport) + await page.addInitScript(() => localStorage.setItem('theme', 'light')) + await page.route('**/api/auth/providers', (route) => route.fulfill({ json: providers })) + await page.goto('/en/signin') + + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + await expect(page.locator('main')).toHaveClass(/doc-grid/) + await expect(page.locator('.ui-card')).toBeVisible() + await expect(page.locator('main').getByRole('alert')).toHaveCount(0) + await expect(page.getByLabel('Email')).toBeVisible() + await expectNoHorizontalClipping(page) + await expect(page).toHaveScreenshot(`signin-${viewport.name}-light.png`, { fullPage: true }) + + const themeButton = page.getByRole('button', { name: /theme/i }) + await themeButton.focus() + await page.keyboard.press('Enter') + await page.getByRole('menuitem', { name: 'Dark' }).click() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + await expect(page.locator('html')).toHaveClass(/dark/) + + await page.keyboard.press('Tab') + await expect(page.locator(':focus')).not.toHaveCount(0) + await expect(page).toHaveScreenshot(`signin-${viewport.name}-dark.png`, { fullPage: true }) + }) +} + +async function expectNoHorizontalClipping(page: import('@playwright/test').Page) { + expect( + await page.evaluate( + () => Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) <= window.innerWidth + ) + ).toBe(true) +} diff --git a/e2e/signin.spec.ts-snapshots/signin-compact-600-dark-linux.png b/e2e/signin.spec.ts-snapshots/signin-compact-600-dark-linux.png new file mode 100644 index 0000000..cb2c2e7 Binary files /dev/null and b/e2e/signin.spec.ts-snapshots/signin-compact-600-dark-linux.png differ diff --git a/e2e/signin.spec.ts-snapshots/signin-compact-600-light-linux.png b/e2e/signin.spec.ts-snapshots/signin-compact-600-light-linux.png new file mode 100644 index 0000000..9dfebea Binary files /dev/null and b/e2e/signin.spec.ts-snapshots/signin-compact-600-light-linux.png differ diff --git a/e2e/signin.spec.ts-snapshots/signin-phone-390-dark-linux.png b/e2e/signin.spec.ts-snapshots/signin-phone-390-dark-linux.png new file mode 100644 index 0000000..a872b0c Binary files /dev/null and b/e2e/signin.spec.ts-snapshots/signin-phone-390-dark-linux.png differ diff --git a/e2e/signin.spec.ts-snapshots/signin-phone-390-light-linux.png b/e2e/signin.spec.ts-snapshots/signin-phone-390-light-linux.png new file mode 100644 index 0000000..be062c2 Binary files /dev/null and b/e2e/signin.spec.ts-snapshots/signin-phone-390-light-linux.png differ diff --git a/e2e/workspace.spec.ts b/e2e/workspace.spec.ts new file mode 100644 index 0000000..1637296 --- /dev/null +++ b/e2e/workspace.spec.ts @@ -0,0 +1,231 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test' + +const runFullLoop = process.env.DOC_E2E_FULL_LOOP === '1' +const mailpitBase = process.env.DOC_E2E_MAILPIT_URL || 'http://127.0.0.1:8025' + +test.skip(!runFullLoop, 'Requires the zero-credential Docker stack; run npm run test:e2e:full after doc up.') +test.setTimeout(120_000) + +test('authenticated workspace remains usable, persistent, responsive, and localized', async ({ page, request }) => { + const email = `doc-e2e-${Date.now()}@example.test` + await page.setViewportSize({ width: 1280, height: 900 }) + await page.addInitScript(() => localStorage.setItem('theme', 'light')) + await signInWithMailpit(page, request, email) + await page.goto('/en/work') + + await expect(page).toHaveURL(/\/en\/user-info$/, { timeout: 30_000 }) + await expect(page.getByRole('heading', { name: 'Complete your profile' })).toBeVisible() + await expect(page.getByLabel('Email')).toHaveValue(email) + await page.getByLabel('Name', { exact: true }).fill('Doc E2E') + await page.getByRole('button', { name: 'Submit' }).click() + await expect(page).toHaveURL(/\/en\/work\/[\w-]+/, { timeout: 30_000 }) + const docId = page.url().split('/').at(-1)! + const contentPanel = page.locator('#work-content-panel') + await expect(contentPanel).toBeVisible() + await expect(page.locator('#work-content-scroll-container')).toBeVisible() + await expect(page.locator('#work-content-container')).toBeVisible() + await expect(page.locator('main#workspace-main')).toBeVisible() + const editor = page.locator('[contenteditable="true"]') + await expect(editor).toBeVisible() + await expect(page.getByRole('banner', { name: 'Document toolbar' })).toBeVisible() + await expect(page.locator('[role="collaborative-state"]')).toHaveAttribute('data-title', 'connected', { + timeout: 30_000, + }) + + const marker = 'browser-loop-persisted' + const title = `First document ${marker}` + await page.locator('#DOC_TITLE_INPUT_ID').fill(title) + await editor.locator('p').last().click() + await page.keyboard.press('End') + await page.keyboard.type(marker) + await expect(editor).toContainText(marker) + + await expect + .poll(async () => documentIsPersisted(page, docId, title, marker), { + timeout: 30_000, + intervals: [250, 500, 1_000], + message: 'title and collaborative body are persisted by the server APIs', + }) + .toBe(true) + + const iconResponse = await page.request.patch(`/api/doc/${encodeURIComponent(docId)}`, { + data: { icon: '📄' }, + }) + expect(iconResponse.ok()).toBe(true) + expect(await iconResponse.json()).toMatchObject({ errno: 0 }) + await page.reload() + await expect(page.locator('#DOC_TITLE_INPUT_ID')).toHaveValue(title) + await expect(page.locator('[contenteditable="true"]')).toContainText(marker) + await expect(page).toHaveURL(/\/en\/work\/[\w-]+/) + await expect(page.locator('[role="collaborative-state"]')).toHaveAttribute('data-title', 'connected', { + timeout: 30_000, + }) + + await page.locator('footer').evaluate((footer) => { + Array.from(footer.children).forEach((child) => ((child as HTMLElement).style.visibility = 'hidden')) + }) + const screenshotOptions = { fullPage: true } + await expectNoHorizontalClipping(page) + await expect(page).toHaveScreenshot('workspace-desktop-light.png', screenshotOptions) + await chooseTheme(page, 'Dark') + await expect(page).toHaveScreenshot('workspace-desktop-dark.png', screenshotOptions) + await chooseTheme(page, 'Light') + + await page.setViewportSize({ width: 704, height: 900 }) + await assertCompactTopbar(page) + const navigationButton = page.getByRole('button', { name: 'Open document navigation' }) + await expect(navigationButton).toHaveAttribute('aria-expanded', 'false') + await expect(page.getByText('Documents')).toHaveCount(0) + + await navigationButton.click() + const navigationDialog = page.getByRole('dialog', { name: 'Document navigation' }) + await expect(navigationDialog).toBeVisible() + await navigationDialog.getByRole('button', { name: 'Search' }).click() + const searchDialog = page.getByRole('dialog', { name: 'Search' }) + await expect(searchDialog).toBeVisible() + const searchInput = searchDialog.locator('input') + await expect(searchInput).toBeFocused() + await page.keyboard.press('Tab') + expect(await searchDialog.evaluate((dialog) => dialog.contains(document.activeElement))).toBe(true) + await page.keyboard.press('Escape') + await expect(searchDialog).toBeHidden() + await expect(navigationDialog).toBeVisible() + await navigationDialog.getByRole('button', { name: 'Close document navigation' }).click() + await expect(navigationDialog).toBeHidden() + await expect(navigationButton).toBeFocused() + + await exerciseAiDrawer(page, 704) + + await navigationButton.click() + await expect(navigationDialog).toBeVisible() + await page.setViewportSize({ width: 1280, height: 900 }) + await expect(page.getByRole('button', { name: 'Open document navigation' })).toHaveCount(0) + await expect(page.getByRole('dialog', { name: 'Document navigation' })).toHaveCount(0) + await page.setViewportSize({ width: 704, height: 900 }) + await expect(page.getByRole('button', { name: 'Open document navigation' })).toHaveAttribute('aria-expanded', 'false') + + await page.setViewportSize({ width: 390, height: 844 }) + await assertCompactTopbar(page) + await expect(page).toHaveScreenshot('workspace-phone-390-light.png', screenshotOptions) + await chooseTheme(page, 'Dark') + await expect(page).toHaveScreenshot('workspace-phone-390-dark.png', screenshotOptions) + await exerciseAiDrawer(page, 390) + + await page.getByRole('button', { name: 'Change language, current en' }).click() + await page.getByRole('menuitem', { name: /中文/ }).click() + await expect(page).toHaveURL(/\/zh-cn\/work\/[\w-]+/) + await page.getByRole('button', { name: 'AI 写作' }).click() + await expect(page.getByPlaceholder('输入 AI 指令,如:根据标题写大纲')).toBeVisible() + await page.getByRole('button', { name: '关闭' }).click() + await expectNoHorizontalClipping(page) +}) + +async function assertCompactTopbar(page: Page) { + const toolbar = page.getByRole('banner', { name: 'Document toolbar' }) + await expect(toolbar).toBeVisible() + const box = await toolbar.boundingBox() + expect(box).toBeTruthy() + expect(box!.x).toBeGreaterThanOrEqual(0) + expect(box!.x + box!.width).toBeLessThanOrEqual((page.viewportSize()?.width || 0) + 1) + await expectNoHorizontalClipping(page) + + const actionsButton = page.getByRole('button', { name: 'Document actions' }) + await expect(actionsButton).toBeVisible() + await expect(actionsButton).toBeEnabled() + await actionsButton.click() + await expect(page.getByRole('button', { name: 'Duplicate' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Move' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Export PDF' })).toBeVisible() + await page.keyboard.press('Escape') +} + +async function exerciseAiDrawer(page: Page, width: number) { + const contentPanel = page.locator('#work-content-panel') + const widthBefore = (await contentPanel.boundingBox())?.width || 0 + const trigger = page.getByRole('button', { name: 'AI Writing' }) + await trigger.click() + await expect(trigger).toHaveAttribute('aria-expanded', 'true') + const drawer = page.getByTestId('ai-panel-drawer') + await expect(drawer).toBeVisible() + expect(await drawer.evaluate((panel) => panel.contains(document.activeElement))).toBe(true) + await expect(page.getByPlaceholder('Input AI command, such as: outline based on title')).toBeVisible() + expect((await contentPanel.boundingBox())?.width).toBeGreaterThanOrEqual(widthBefore - 1) + expect((await drawer.boundingBox())?.width).toBeLessThanOrEqual(width + 1) + await page.keyboard.press('Tab') + expect(await drawer.evaluate((panel) => panel.contains(document.activeElement))).toBe(true) + await page.keyboard.press('Escape') + await expect(drawer).toBeHidden() + await expect(trigger).toBeFocused() +} + +async function chooseTheme(page: Page, theme: 'Light' | 'Dark') { + await page.getByRole('button', { name: /theme/i }).click() + await page.getByRole('menuitem', { name: theme }).click() + await expect(page.locator('html')).toHaveAttribute('data-theme', theme.toLowerCase()) +} + +async function documentIsPersisted(page: Page, docId: string, title: string, marker: string) { + const [documentResponse, listResponse] = await Promise.all([ + page.request.get(`/api/doc/${encodeURIComponent(docId)}`), + page.request.get('/api/doc'), + ]) + if (!documentResponse.ok() || !listResponse.ok()) return false + const documentPayload = await documentResponse.json() + const listPayload = await listResponse.json() + const content = String(documentPayload?.data?.content || '') + const savedDoc = Array.isArray(listPayload?.data) + ? listPayload.data.find((document: { id?: string }) => document.id === docId) + : null + return content.includes(marker) && savedDoc?.title === title +} + +async function expectNoHorizontalClipping(page: Page) { + expect( + await page.evaluate( + () => Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) <= window.innerWidth + ) + ).toBe(true) +} + +async function signInWithMailpit(page: Page, request: APIRequestContext, email: string) { + const previousIds = new Set((await listMessages(request)).map(messageId)) + await page.goto('/en/signin') + await page.getByLabel('Email').fill(email) + await page.locator('button[type="submit"]').click() + + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + const message = (await listMessages(request)).find( + (item) => !previousIds.has(messageId(item)) && JSON.stringify(item).toLowerCase().includes(email.toLowerCase()) + ) + if (message) { + const response = await request.get(`${mailpitBase}/api/v1/message/${encodeURIComponent(messageId(message))}`) + const detail = await response.json() + const text = collectStrings(detail).join('\n').replaceAll('&', '&') + const match = text.match(/https?:\/\/[^\s"'<>]+\/api\/auth\/callback\/nodemailer[^\s"'<>]*/i) + expect(match, 'Mailpit message contains an Auth.js callback').toBeTruthy() + await page.goto(match![0]) + return + } + await page.waitForTimeout(500) + } + throw new Error('Mailpit did not receive the sign-in email') +} + +async function listMessages(request: APIRequestContext): Promise { + const response = await request.get(`${mailpitBase}/api/v1/messages?limit=100`) + expect(response.ok()).toBe(true) + const payload = await response.json() + return Array.isArray(payload?.messages) ? payload.messages : [] +} + +function messageId(message: any) { + return String(message?.ID ?? message?.Id ?? message?.id ?? '') +} + +function collectStrings(value: unknown, output: string[] = []): string[] { + if (typeof value === 'string') output.push(value) + else if (Array.isArray(value)) value.forEach((item) => collectStrings(item, output)) + else if (value && typeof value === 'object') Object.values(value).forEach((item) => collectStrings(item, output)) + return output +} diff --git a/e2e/workspace.spec.ts-snapshots/workspace-desktop-dark-linux.png b/e2e/workspace.spec.ts-snapshots/workspace-desktop-dark-linux.png new file mode 100644 index 0000000..9c42041 Binary files /dev/null and b/e2e/workspace.spec.ts-snapshots/workspace-desktop-dark-linux.png differ diff --git a/e2e/workspace.spec.ts-snapshots/workspace-desktop-light-linux.png b/e2e/workspace.spec.ts-snapshots/workspace-desktop-light-linux.png new file mode 100644 index 0000000..71c82fd Binary files /dev/null and b/e2e/workspace.spec.ts-snapshots/workspace-desktop-light-linux.png differ diff --git a/e2e/workspace.spec.ts-snapshots/workspace-phone-390-dark-linux.png b/e2e/workspace.spec.ts-snapshots/workspace-phone-390-dark-linux.png new file mode 100644 index 0000000..bd2deda Binary files /dev/null and b/e2e/workspace.spec.ts-snapshots/workspace-phone-390-dark-linux.png differ diff --git a/e2e/workspace.spec.ts-snapshots/workspace-phone-390-light-linux.png b/e2e/workspace.spec.ts-snapshots/workspace-phone-390-light-linux.png new file mode 100644 index 0000000..fbb11d6 Binary files /dev/null and b/e2e/workspace.spec.ts-snapshots/workspace-phone-390-light-linux.png differ diff --git a/messages/en.json b/messages/en.json index e373883..0b4d244 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,7 +1,15 @@ { "common": { "brandName": "doc", - "notSupportMobile": "Mobile devices are not supported yet" + "notSupportMobile": "Mobile devices are not supported yet", + "openDocumentNavigation": "Open document navigation", + "closeDocumentNavigation": "Close document navigation", + "documentNavigation": "Document navigation", + "documentToolbar": "Document toolbar", + "documentActions": "Document actions", + "moreDocumentActions": "More document actions", + "changeLanguage": "Change language, current {locale}", + "closeDialog": "Close dialog" }, "metadata": { "title": "doc — self-hosted collaborative document infrastructure", @@ -75,7 +83,9 @@ "haveLogin": "You have logged in", "login": "Login", "logout": "Logout", - "logoutConfirm": "Are you sure you want to log out?" + "logoutConfirm": "Are you sure you want to log out?", + "profileTitle": "Complete your profile", + "profileDescription": "Choose the name collaborators will see, then continue to your workspace." }, "personalAccessTokens": { "title": "Personal access tokens", @@ -116,6 +126,9 @@ "others": "OR CONTINUE WITH", "email": "Email", "enterYourEmail": "Enter your email address", + "invalidEmail": "Enter a valid email address.", + "requiredEmail": "Email is required.", + "signInFailed": "Sign-in failed. Please try again.", "withEmail": "Continue with Email", "loading": "Loading sign-in options…", "unavailable": "No sign-in method is configured. Ask the instance operator to configure one." @@ -127,6 +140,11 @@ "userAndSetting": { "title": "User / Setting", "changeUerInfo": "Change User Info", + "email": "Email", + "name": "Name", + "namePlaceholder": "Input your name", + "avatar": "Avatar", + "avatarPlaceholder": "https://github.com/username.png", "submit": "Submit", "success": "Success" }, @@ -191,10 +209,14 @@ "notFound": "Cannot find the doc", "create": "Create", "titleInputPlaceholder": "Doc title...", + "titleInputLabel": "Document title", "exportPDF": "Export PDF", "downloadPDF": "Click to download PDF manually", "createdAt": "CreatedAt", - "updatedAt": "UpdatedAt" + "updatedAt": "UpdatedAt", + "collapseDocument": "Collapse {title}", + "expandDocument": "Expand {title}", + "createDocumentUnder": "Create document under {title}" }, "shareDoc": { "share": "Share", @@ -341,6 +363,8 @@ "inputPlaceholder2": "Input AI command, such as: outline based on title", "inputPlaceholder3": "Input AI command for selected content, such as: expand this content", "AIgenerating": "AI generating...", + "sendInstruction": "Send AI instruction", + "stopGenerating": "Stop AI generation", "copy": "Copy", "replace": "Replace", "insert": "Insert", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 4b237cf..7399889 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -1,7 +1,15 @@ { "common": { "brandName": "doc", - "notSupportMobile": "不支持移动端,请使用 PC 浏览器访问" + "notSupportMobile": "不支持移动端,请使用 PC 浏览器访问", + "openDocumentNavigation": "打开文档导航", + "closeDocumentNavigation": "关闭文档导航", + "documentNavigation": "文档导航", + "documentToolbar": "文档工具栏", + "documentActions": "文档操作", + "moreDocumentActions": "更多文档操作", + "changeLanguage": "切换语言,当前为 {locale}", + "closeDialog": "关闭对话框" }, "metadata": { "title": "doc — 自托管协作文档基础设施", @@ -75,7 +83,9 @@ "haveLogin": "你已经登录", "login": "登录", "logout": "退出", - "logoutConfirm": "是否退出登录?" + "logoutConfirm": "是否退出登录?", + "profileTitle": "完善个人资料", + "profileDescription": "设置协作者可见的名称,然后进入工作区。" }, "personalAccessTokens": { "title": "个人访问令牌", @@ -116,6 +126,9 @@ "others": "使用其他方式", "email": "邮箱", "enterYourEmail": "输入你的邮箱", + "invalidEmail": "请输入有效的邮箱地址。", + "requiredEmail": "请输入邮箱地址。", + "signInFailed": "登录失败,请重试。", "withEmail": "使用邮箱登录", "loading": "正在加载登录方式…", "unavailable": "当前未配置可用的登录方式,请联系实例管理员。" @@ -127,6 +140,11 @@ "userAndSetting": { "title": "用户/设置", "changeUerInfo": "修改用户信息", + "email": "邮箱", + "name": "名称", + "namePlaceholder": "输入你的名称", + "avatar": "头像", + "avatarPlaceholder": "https://github.com/username.png", "submit": "提交", "success": "成功" }, @@ -191,10 +209,14 @@ "notFound": "找不到该文档", "create": "新建文档", "titleInputPlaceholder": "请输入标题...", + "titleInputLabel": "文档标题", "exportPDF": "导出 PDF", "downloadPDF": "点击手动下载 PDF", "createdAt": "创建时间", - "updatedAt": "更新时间" + "updatedAt": "更新时间", + "collapseDocument": "收起 {title}", + "expandDocument": "展开 {title}", + "createDocumentUnder": "在 {title} 下新建文档" }, "shareDoc": { "share": "分享", @@ -341,6 +363,8 @@ "inputPlaceholder2": "输入 AI 指令,如:根据标题写大纲", "inputPlaceholder3": "针对选中内容,输入 AI 指令,如:扩展一下这段内容", "AIgenerating": "AI 生成中......", + "sendInstruction": "发送 AI 指令", + "stopGenerating": "停止 AI 生成", "copy": "复制", "replace": "替换", "insert": "插入", diff --git a/package-lock.json b/package-lock.json index 7432968..f342958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,6 +106,7 @@ "devDependencies": { "@commitlint/cli": "^19.2.1", "@commitlint/config-conventional": "^19.1.0", + "@playwright/test": "^1.58.2", "@tailwindcss/typography": "^0.5.12", "@testing-library/react": "^16.1.0", "@types/ali-oss": "^6.16.11", @@ -132,7 +133,7 @@ "postcss": "^8.5.6", "prettier": "^3.2.5", "prisma": "^5.11.0", - "tailwindcss": "^3.3.0", + "tailwindcss": "^3.4.19", "typescript": "^5", "vite": "8.1.5", "vitest": "4.1.10" @@ -2357,6 +2358,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -13983,6 +14000,52 @@ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/po-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", diff --git a/package.json b/package.json index bd030ce..c97fc8d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "db:preflight": "prisma db execute --file prisma/preflight/ensure-share-relation-unique.sql --schema prisma/schema.prisma", "db:push": "npm run db:preflight && prisma db push", "test": "vitest", + "test:e2e": "playwright test", + "test:e2e:full": "DOC_E2E_FULL_LOOP=1 playwright test e2e/workspace.spec.ts", "test:cli": "npm test --workspace @fullstack-ai-infra/doc-cli", "test-ci": "vitest run && npm run test:cli", "verify:local-loop": "node scripts/verify-local-loop.mjs", @@ -128,6 +130,7 @@ "devDependencies": { "@commitlint/cli": "^19.2.1", "@commitlint/config-conventional": "^19.1.0", + "@playwright/test": "^1.58.2", "@tailwindcss/typography": "^0.5.12", "@testing-library/react": "^16.1.0", "@types/ali-oss": "^6.16.11", @@ -154,7 +157,7 @@ "postcss": "^8.5.6", "prettier": "^3.2.5", "prisma": "^5.11.0", - "tailwindcss": "^3.3.0", + "tailwindcss": "^3.4.19", "typescript": "^5", "vite": "8.1.5", "vitest": "4.1.10" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f72b11e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test' + +const externalBaseURL = process.env.DOC_E2E_BASE_URL + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: externalBaseURL || 'http://localhost:3101', + channel: 'chromium', + trace: 'retain-on-failure', + ...devices['Desktop Chrome'], + }, + webServer: externalBaseURL + ? undefined + : { + command: 'npm run dev -- --hostname 127.0.0.1 --port 3101', + url: 'http://localhost:3101/en/signin', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + AUTH_SECRET: 'local-visual-test-only', + NEXTAUTH_URL: 'http://localhost:3101', + NEXT_PUBLIC_APP_URL: 'http://localhost:3101', + }, + }, +}) diff --git a/src/__tests__/components/ai-input.test.tsx b/src/__tests__/components/ai-input.test.tsx new file mode 100644 index 0000000..51794ff --- /dev/null +++ b/src/__tests__/components/ai-input.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { NextIntlClientProvider } from 'next-intl' +import AIInput from '@/components/ai-panel/ai-input' +import enMessages from '../../../messages/en.json' +import zhMessages from '../../../messages/zh-cn.json' + +afterEach(cleanup) + +function renderInput(locale: 'en' | 'zh-cn') { + const messages = locale === 'en' ? enMessages : zhMessages + return render( + + + + ) +} + +test('uses the English AI input placeholder in the English locale', () => { + renderInput('en') + expect(screen.getByPlaceholderText('Input AI command, such as: outline based on title')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Send AI instruction' })).toBeTruthy() + expect(screen.queryByPlaceholderText(/输入 AI 指令/)).toBeNull() +}) + +test('uses the Chinese AI input placeholder in the Chinese locale', () => { + renderInput('zh-cn') + expect(screen.getByPlaceholderText('输入 AI 指令,如:根据标题写大纲')).toBeTruthy() + expect(screen.getByRole('button', { name: '发送 AI 指令' })).toBeTruthy() +}) diff --git a/src/__tests__/components/auto-growing-title.test.tsx b/src/__tests__/components/auto-growing-title.test.tsx new file mode 100644 index 0000000..5e6f6b9 --- /dev/null +++ b/src/__tests__/components/auto-growing-title.test.tsx @@ -0,0 +1,50 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import AutoGrowingTitle from '@/components/auto-growing-title' + +describe('AutoGrowingTitle', () => { + let resize: (() => void) | undefined + const disconnect = vi.fn() + + beforeEach(() => { + resize = undefined + disconnect.mockReset() + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resize = () => callback([], this as unknown as ResizeObserver) + } + observe() {} + disconnect() { + disconnect() + } + } + ) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('is an accessible multiline field and forwards title edits', () => { + const onChange = vi.fn() + render() + + const title = screen.getByRole('textbox', { name: 'Document title' }) + expect(title.tagName).toBe('TEXTAREA') + fireEvent.change(title, { target: { value: 'Updated title' } }) + expect(onChange).toHaveBeenCalledOnce() + }) + + it('recalculates height when wrapping width changes', () => { + render() + const title = screen.getByRole('textbox', { name: 'Document title' }) as HTMLTextAreaElement + Object.defineProperty(title, 'scrollHeight', { configurable: true, value: 112 }) + + act(() => resize?.()) + + expect(title.style.height).toBe('112px') + }) +}) diff --git a/src/__tests__/components/change-theme.test.tsx b/src/__tests__/components/change-theme.test.tsx index fb9b950..a9bd0d7 100644 --- a/src/__tests__/components/change-theme.test.tsx +++ b/src/__tests__/components/change-theme.test.tsx @@ -1,5 +1,5 @@ import { expect, test, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import NextIntlClientProviderWrapper from '../utils/next-intl-client-provider-wrapper' import ChangeTheme from '@/components/change-theme' import { ThemeProvider } from '@/components/theme-provider' @@ -28,6 +28,7 @@ test('Change theme component', async () => { ) - const button = screen.getByRole('button') + const button = screen.getByRole('button', { name: 'Change theme' }) expect(button.getAttribute('data-title')).toBe(theme) + await waitFor(() => expect(document.documentElement.dataset.theme).toBe(theme)) }) diff --git a/src/__tests__/components/dialog-store.test.ts b/src/__tests__/components/dialog-store.test.ts new file mode 100644 index 0000000..e573d91 --- /dev/null +++ b/src/__tests__/components/dialog-store.test.ts @@ -0,0 +1,12 @@ +import { beforeEach, expect, test } from 'vitest' +import { useDialogStore } from '@/stores/dialog-store' + +beforeEach(() => { + useDialogStore.setState({ AIPanelOpen: false }) +}) + +test('keeps the AI panel closed until the user opens it', () => { + expect(useDialogStore.getState().AIPanelOpen).toBe(false) + useDialogStore.getState().setAIPanelOpen(true) + expect(useDialogStore.getState().AIPanelOpen).toBe(true) +}) diff --git a/src/__tests__/components/doc-update-status.test.tsx b/src/__tests__/components/doc-update-status.test.tsx index c0a2f6a..fd6225a 100644 --- a/src/__tests__/components/doc-update-status.test.tsx +++ b/src/__tests__/components/doc-update-status.test.tsx @@ -36,6 +36,8 @@ test('Doc update status component', async () => { const state = screen.getByRole('collaborative-state') expect(state.getAttribute('data-title')).toBe('connected') + expect(state.className).toContain('ui-status--source') + expect(state.className).toContain('is-available') // const charCount = screen.getByRole('char-count') // expect(charCount.textContent).toContain(CHAR_COUNT.toString()) diff --git a/src/__tests__/components/responsive-workspace.test.tsx b/src/__tests__/components/responsive-workspace.test.tsx new file mode 100644 index 0000000..cdb66bb --- /dev/null +++ b/src/__tests__/components/responsive-workspace.test.tsx @@ -0,0 +1,122 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import ResponsiveWorkspace from '@/components/responsive-workspace' + +vi.mock('@/components/ui/resizable', () => ({ + ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizablePanel: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizableHandle: () =>