diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 654b1929..aeb6d2a8 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -462,6 +462,50 @@ describe("renderPaperclipWakePrompt", () => { expect(prompt).toContain("named unblock owner/action"); }); + it("preserves Chinese, Japanese, and Hindi issue and comment text in scoped wake prompts", () => { + const title = "验证中文任务"; + const commentBody = [ + "请用中文回复。", + "日本語: 次の手順を書いてください。", + "हिन्दी: कृपया स्थिति बताएं।", + ].join("\n"); + const payload = { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-9452", + title, + status: "in_progress", + workMode: "standard", + }, + commentIds: ["comment-1"], + latestCommentId: "comment-1", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [ + { + id: "comment-1", + body: commentBody, + author: { type: "user", id: "board-user-1" }, + createdAt: "2026-05-15T16:30:00.000Z", + }, + ], + fallbackFetchNeeded: false, + }; + + const serialized = stringifyPaperclipWakePayload(payload); + expect(serialized).toContain(title); + expect(serialized).toContain("日本語"); + expect(serialized).toContain("हिन्दी"); + expect(JSON.parse(serialized ?? "{}")).toMatchObject({ + issue: { title }, + comments: [{ body: commentBody }], + }); + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain(`- issue: PAP-9452 ${title}`); + expect(prompt).toContain(commentBody); + }); + it("renders planning-mode directives for assignment and comment wakes", () => { const assignmentPrompt = renderPaperclipWakePrompt({ reason: "issue_assigned", diff --git a/server/src/__tests__/multilingual-issues-routes.test.ts b/server/src/__tests__/multilingual-issues-routes.test.ts new file mode 100644 index 00000000..da9f0aa3 --- /dev/null +++ b/server/src/__tests__/multilingual-issues-routes.test.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { companies, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import type { StorageService } from "../storage/types.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres multilingual issue route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("multilingual issue routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let app!: ReturnType; + let companyId!: string; + + const title = "验证中文任务"; + const description = [ + "请用中文回复并保留上下文。", + "日本語: 次の手順を書いてください。", + "हिन्दी: कृपया स्थिति बताएं।", + ].join("\n"); + const firstReply = [ + "结果: 中文响应保留。", + "日本語の返信も保持。", + "हिन्दी उत्तर भी सुरक्षित है।", + ].join("\n"); + const completionNote = [ + "完成: 已验证中文。", + "日本語: 完了しました。", + "हिन्दी: सत्यापन पूरा हुआ।", + ].join("\n"); + const documentBody = [ + "# QA notes", + "", + "- 中文: 可以创建、读取、搜索、评论。", + "- 日本語: ドキュメント本文を保持します。", + "- हिन्दी: दस्तावेज़ पाठ सुरक्षित रहता है।", + ].join("\n"); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-multilingual-issues-"); + db = createDb(tempDb.connectionString); + companyId = randomUUID(); + app = createApp(companyId); + + await db.insert(companies).values({ + id: companyId, + name: "Multilingual tenant", + issuePrefix: "LNG", + requireBoardApprovalForNewAgents: false, + }); + }, 20_000); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createStorage(): StorageService { + return { + provider: "local_disk", + putFile: vi.fn(async () => { + throw new Error("Unexpected storage.putFile call in multilingual issue route test"); + }), + getObject: vi.fn(async () => { + throw new Error("Unexpected storage.getObject call in multilingual issue route test"); + }), + headObject: vi.fn(async () => ({ exists: false })), + deleteObject: vi.fn(async () => undefined), + }; + } + + function createApp(companyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "cloud-user-1", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + source: "cloud_tenant", + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api", issueRoutes(db, createStorage())); + app.use(errorHandler); + return app; + } + + it("creates an issue with multilingual title and description", async () => { + const createRes = await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ + title, + description, + status: "todo", + priority: "medium", + }); + + expect(createRes.status, JSON.stringify(createRes.body)).toBe(201); + expect(createRes.body).toMatchObject({ + title, + description, + status: "todo", + priority: "medium", + identifier: "LNG-1", + }); + }); + + it("reads the multilingual title and description unchanged", async () => { + const getRes = await request(app).get("/api/issues/LNG-1"); + expect(getRes.status, JSON.stringify(getRes.body)).toBe(200); + expect(getRes.body.title).toBe(title); + expect(getRes.body.description).toBe(description); + }); + + it("finds the issue by Chinese search text", async () => { + const searchRes = await request(app).get(`/api/companies/${companyId}/issues`).query({ q: "中文" }); + expect(searchRes.status, JSON.stringify(searchRes.body)).toBe(200); + expect(searchRes.body.map((issue: { identifier: string }) => issue.identifier)).toContain("LNG-1"); + }); + + it("preserves multilingual comment bodies", async () => { + const commentRes = await request(app) + .post("/api/issues/LNG-1/comments") + .send({ body: firstReply }); + expect(commentRes.status, JSON.stringify(commentRes.body)).toBe(201); + expect(commentRes.body.body).toBe(firstReply); + }); + + it("preserves multilingual document bodies", async () => { + const documentRes = await request(app) + .put("/api/issues/LNG-1/documents/qa-notes") + .send({ + title: "Multilingual QA", + format: "markdown", + body: documentBody, + }); + expect(documentRes.status, JSON.stringify(documentRes.body)).toBe(201); + expect(documentRes.body.body).toBe(documentBody); + }); + + it("preserves multilingual completion comments", async () => { + const completeRes = await request(app) + .patch("/api/issues/LNG-1") + .send({ status: "done", comment: completionNote }); + expect(completeRes.status, JSON.stringify(completeRes.body)).toBe(200); + expect(completeRes.body.status).toBe("done"); + expect(completeRes.body.comment.body).toBe(completionNote); + }); + + it("lists multilingual comments in write order", async () => { + const commentsRes = await request(app).get("/api/issues/LNG-1/comments").query({ order: "asc" }); + expect(commentsRes.status, JSON.stringify(commentsRes.body)).toBe(200); + expect(commentsRes.body.map((comment: { body: string }) => comment.body)).toEqual([ + firstReply, + completionNote, + ]); + }); + + it("exposes multilingual issue text in heartbeat context", async () => { + const heartbeatContextRes = await request(app).get("/api/issues/LNG-1/heartbeat-context"); + expect(heartbeatContextRes.status, JSON.stringify(heartbeatContextRes.body)).toBe(200); + expect(heartbeatContextRes.body.issue.title).toBe(title); + expect(heartbeatContextRes.body.issue.description).toBe(description); + expect(heartbeatContextRes.body.commentCursor.totalComments).toBe(2); + }); +}); diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 569d1e8f..7eea9baf 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -588,6 +588,49 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("submits Chinese, Japanese, and Hindi issue text without normalization", async () => { + const title = "验证中文任务"; + const description = [ + "请用中文回复。", + "日本語: 次の手順を書いてください。", + "हिन्दी: कृपया स्थिति बताएं।", + ].join("\n"); + + const { root } = renderDialog(container); + await flush(); + + const titleInput = container.querySelector('textarea[placeholder="Issue title"]') as HTMLTextAreaElement | null; + const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null; + expect(titleInput).not.toBeNull(); + expect(descriptionInput).not.toBeNull(); + + await typeTextareaValue(titleInput!, title); + await typeTextareaValue(descriptionInput!, description); + + const submitButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Issue")); + expect(submitButton).not.toBeUndefined(); + await vi.waitFor(() => { + expect(submitButton?.hasAttribute("disabled")).toBe(false); + }); + + await act(async () => { + submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(mockIssuesApi.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + title, + description, + workMode: "standard", + }), + ); + + act(() => root.unmount()); + }); + it("submits planning work mode when planning is selected", async () => { const { root } = renderDialog(container); await flush();