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
89 changes: 89 additions & 0 deletions e2e/citation-dedupe.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { expect, test } from "@playwright/test"

test.setTimeout(60_000)

test("keeps duplicate source labels clickable for separate documents", async ({
context,
page,
}) => {
let firstSourceChunkRequests = 0
let secondSourceChunkRequests = 0

await context.addCookies([
{
name: "better-auth.session_token",
value: "playwright",
url: "http://localhost:3000",
},
])

await page.route("**/api/sources/source_first/chunks**", async (route) => {
firstSourceChunkRequests += 1
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
chunks: [
{
chunkId: "chunk_first",
documentId: "doc_first",
sectionPath: "Root",
type: "text",
content: "First report source content.",
sourceTitle: "report.pdf",
},
],
pagination: {
page: 1,
pageSize: 50,
total: 1,
totalPages: 1,
},
}),
})
})
await page.route("**/api/sources/source_second/chunks**", async (route) => {
secondSourceChunkRequests += 1
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
chunks: [
{
chunkId: "chunk_second",
documentId: "doc_second",
sectionPath: "Root",
type: "text",
content: "Second report source content.",
sourceTitle: "report.pdf",
},
],
pagination: {
page: 1,
pageSize: 50,
total: 1,
totalPages: 1,
},
}),
})
})

await page.goto("/e2e/citation-dedupe")

const chatPanel = page.getByTestId("desktop-chat-panel")
const duplicateSourceLinks = chatPanel.getByRole("button", {
name: "Open source report.pdf",
})
await expect(duplicateSourceLinks).toHaveCount(2)

const secondSourceRequestsBeforeClick = secondSourceChunkRequests
await duplicateSourceLinks.nth(1).click()
await expect(page.getByText("Second report source content.")).toBeVisible()
expect(secondSourceChunkRequests).toBeGreaterThan(
secondSourceRequestsBeforeClick,
)

await duplicateSourceLinks.first().click()
await expect(page.getByText("First report source content.")).toBeVisible()
expect(firstSourceChunkRequests).toBeGreaterThan(0)
})
66 changes: 66 additions & 0 deletions e2e/workspace-responsive.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { expect, test } from "@playwright/test"

test("fits desktop notebook panels inside a 13-inch viewport", async ({
context,
page,
}) => {
await context.addCookies([
{
name: "better-auth.session_token",
value: "playwright",
url: "http://localhost:3000",
},
])
await page.setViewportSize({ width: 1280, height: 832 })
await page.goto("/e2e/citation-dedupe")

const layout = page.getByTestId("desktop-panel-layout")
const chatPanel = page.getByTestId("desktop-chat-panel")
await expect(layout).toBeVisible()

await expect
.poll(async () => {
return layout.evaluate((element) => {
return element.scrollWidth <= element.clientWidth
})
})
.toBe(true)

const measurements = await layout.evaluate((element) => {
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}
})
const chatBounds = await chatPanel.boundingBox()

expect(measurements.scrollWidth).toBeLessThanOrEqual(
measurements.clientWidth,
)
expect(chatBounds?.x).toBeGreaterThanOrEqual(0)
expect((chatBounds?.x ?? 0) + (chatBounds?.width ?? 0)).toBeLessThanOrEqual(
measurements.clientWidth,
)
})

test("uses the tabbed notebook layout below the desktop panel minimum", async ({
context,
page,
}) => {
await context.addCookies([
{
name: "better-auth.session_token",
value: "playwright",
url: "http://localhost:3000",
},
])
await page.setViewportSize({ width: 1099, height: 832 })
await page.goto("/e2e/citation-dedupe")

await expect(page.getByTestId("desktop-panel-layout")).toBeHidden()
await expect(
page.getByRole("tab", {
name: /Assistant/u,
}),
).toBeVisible()
})
19 changes: 13 additions & 6 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { defineConfig, devices } from "@playwright/test"

const shouldUseExternalWebServer =
process.env.PLAYWRIGHT_EXTERNAL_WEB_SERVER === "1"

export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.e2e.ts",
Expand All @@ -17,10 +20,14 @@ export default defineConfig({
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
timeout: 60_000,
},
...(shouldUseExternalWebServer
? {}
: {
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
timeout: 60_000,
},
}),
})
83 changes: 83 additions & 0 deletions src/app/e2e/citation-dedupe/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { notFound } from "next/navigation"

import { WorkspaceShell } from "@/components/workspace-shell"
import type { ChatMessageView, ChatThreadView } from "@/domains/chat/types"
import type { SourceView } from "@/domains/sources/types"

const duplicateTitleSources: SourceView[] = [
{
id: "source_first",
title: "report.pdf",
mimeType: "application/pdf",
status: "ready",
documentId: "doc_first",
chunkCount: 1,
},
{
id: "source_second",
title: "report.pdf",
mimeType: "application/pdf",
status: "ready",
documentId: "doc_second",
chunkCount: 1,
},
]

const chatThreads: ChatThreadView[] = [
{
id: "thread_1",
title: "Duplicate source labels",
createdAt: "2026-05-17T00:00:00.000Z",
updatedAt: "2026-05-17T00:00:00.000Z",
},
]

const chatMessages: ChatMessageView[] = [
{
id: "assistant_1",
role: "assistant",
content: "Both reports are relevant to the answer.",
citations: [
{
chunkType: "text",
score: 0.91,
source: {
documentId: "doc_first",
sourceFileName: "report.pdf",
sectionPath: "Root",
},
},
{
chunkType: "text",
score: 0.89,
source: {
documentId: "doc_second",
sourceFileName: "report.pdf",
sectionPath: "Root",
},
},
],
},
]

export default function CitationDedupeTestPage() {
if (process.env.NODE_ENV === "production") notFound()

return (
<WorkspaceShell
user={{
id: "user_playwright",
name: "Playwright",
email: "playwright@example.com",
}}
workspace={{
id: "workspace_playwright",
namespace: "notebook-playwright",
}}
sources={duplicateTitleSources}
chatThreads={chatThreads}
activeChatThreadId="thread_1"
chatMessages={chatMessages}
/>
)
}
17 changes: 8 additions & 9 deletions src/app/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock("@/domains/workspace/initial-state", () => ({
}))

import { HomeContent } from "./page"
import { makeWorkspaceInitialStateFailureFixture } from "@/test/workspace-initial-state-failure-fixture"

describe("Home", () => {
afterEach(() => {
Expand All @@ -42,18 +43,16 @@ describe("Home", () => {
})

it("logs a readable page-load failure before rethrowing", async () => {
mocks.loadWorkspaceShellInitialState.mockRejectedValue(
new Error("database connection refused"),
)
const failure = makeWorkspaceInitialStateFailureFixture()

await expect(HomeContent()).rejects.toThrow(
"Workspace initial state failed: database connection refused",
)
mocks.loadWorkspaceShellInitialState.mockRejectedValue(failure.error)

await expect(HomeContent()).rejects.toThrow(failure.boundaryMessage)
expect(mocks.logger.error).toHaveBeenCalledWith(
"workspace: initial state failed",
expect.objectContaining({
error: expect.stringContaining("database connection refused"),
}),
{
error: failure.rootCauseMessage,
},
)
})
})
4 changes: 2 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Suspense } from "react"
import { WorkspaceShell } from "@/components/workspace-shell"
import { loadWorkspaceShellInitialState } from "@/domains/workspace/initial-state"
import { effectOperation } from "@/lib/effect-operation"
import { formatUnknownForLog } from "@/lib/format-log-value"
import { summarizeUnknownError } from "@/lib/format-log-value"
import { logger } from "@/lib/logger"
import { connection } from "next/server"

Expand All @@ -27,7 +27,7 @@ async function loadWorkspaceInitialState(): ReturnType<
return await loadWorkspaceShellInitialState()
} catch (error) {
logger.error("workspace: initial state failed", {
error: formatUnknownForLog(error),
error: summarizeUnknownError(error),
})
throw effectOperation.createBoundaryError(
"Workspace initial state failed",
Expand Down
Loading
Loading