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
5 changes: 4 additions & 1 deletion packages/app/src/components/file-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ beforeAll(async () => {
}))
mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
mock.module("@opencode-ai/ui/tooltip", () => ({
Tooltip: (props: { children?: unknown }) => props.children,
TooltipKeybind: (_props: any) => null,
}))
const mod = await import("./file-tree")
shouldListRoot = mod.shouldListRoot
shouldListExpanded = mod.shouldListExpanded
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/no-mode-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ test("message-part.tsx no longer renders agent pill", async () => {
// After Task 5, HighlightedText drops agents from allRefs and the type union no
// longer includes "agent". Source-grep guards against future regressions that
// re-introduce a styled pill via the same data-highlight marker.
const file = path.join(UI_COMPONENTS, "message-part.tsx")
const text = await fs.readFile(file, "utf8")
const root = path.join(UI_COMPONENTS, "message-part")
const files = [path.join(UI_COMPONENTS, "message-part.tsx"), ...(await walk(root))]
const text = (await Promise.all(files.map((file) => fs.readFile(file, "utf8")))).join("\n")
// Match data-highlight="agent" / 'agent' / `agent` to survive quote-style changes.
expect(text).not.toMatch(/data-highlight\s*=\s*["'`]agent["'`]/)
})
7 changes: 6 additions & 1 deletion packages/app/src/pages/session/session-side-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ beforeAll(async () => {
DropdownMenu.Separator = () => null
return { DropdownMenu }
})
mock.module("@opencode-ai/ui/tooltip", () => ({ TooltipKeybind: (_props: any) => null }))
mock.module("@opencode-ai/ui/tooltip", () => ({
Tooltip: (props: { children?: unknown }) => props.children,
TooltipKeybind: (_props: any) => null,
}))
mock.module("@opencode-ai/ui/resize-handle", () => ({ ResizeHandle: () => null }))
mock.module("@opencode-ai/ui/logo", () => ({ Mark: () => null }))
mock.module("@thisbeyond/solid-dnd", () => ({
Expand Down Expand Up @@ -74,6 +77,8 @@ beforeAll(async () => {
mock.module("@/pages/session/files-tab", () => ({ FilesTab: () => null }))
mock.module("@/pages/session/handoff", () => ({ setSessionHandoff: () => undefined }))
mock.module("@/pages/session/session-layout", () => ({
sessionRouteLayoutKey: (params: { dir: string | undefined; id: string | undefined }) =>
params.dir ? `${params.dir}${params.id ? "/" + params.id : ""}` : "",
useSessionLayout: () => ({
layoutRouteKey: () => "dir/demo",
tabs: () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ beforeAll(async () => {
}))
mock.module("@/utils/persist", () => ({
Persist: {
global: () => ({}),
global: (key: string, legacy?: string[]) => ({ key, legacy }),
},
persisted: (_target: unknown, store: unknown) => store,
}))
Expand Down
129 changes: 129 additions & 0 deletions packages/ui/src/components/message-part-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { expect, test } from "bun:test"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { dirname, join, relative } from "node:path"
import { fileURLToPath } from "node:url"

const COMPONENT_DIR = dirname(fileURLToPath(import.meta.url))
const MESSAGE_PART_DIR = join(COMPONENT_DIR, "message-part")

const expectedParts = ["compaction", "reasoning", "text", "tool"]
const expectedTools = [
"read",
"list",
"glob",
"grep",
"webfetch",
"websearch",
"enter-worktree",
"exit-worktree",
"task",
"agent",
"bash",
"edit",
"write",
"apply_patch",
"todowrite",
"question",
"skill",
]

function sourceFiles(dir: string): string[] {
return readdirSync(dir)
.flatMap((name) => {
const full = join(dir, name)
const stat = statSync(full)
if (stat.isDirectory()) return sourceFiles(full)
if (stat.isFile() && /\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name)) return [full]
return []
})
.sort()
}

function readMessagePartSources() {
return [
readFileSync(join(COMPONENT_DIR, "message-part.tsx"), "utf8"),
...sourceFiles(MESSAGE_PART_DIR).map((file) => readFileSync(file, "utf8")),
].join("\n")
}

test("message-part internals do not import through the facade", () => {
const offenders = sourceFiles(MESSAGE_PART_DIR)
.map((file) => ({
file: relative(COMPONENT_DIR, file),
text: readFileSync(file, "utf8"),
}))
.filter((item) => /from\s+["'](?:\.\.\/message-part|\.\.\/\.\.\/message-part)["']/.test(item.text))

expect(offenders).toEqual([])
})

test("message-part registry stays independent from render modules", () => {
const source = readFileSync(join(MESSAGE_PART_DIR, "registry.ts"), "utf8")
const imports = [...source.matchAll(/import(?:\s+type)?(?:[\s\S]*?\sfrom\s*)?["']([^"']+)["']/g)].map(
(match) => match[1],
)
const renderModuleImports = imports.filter(
(path) => path === "./message-router" || path.startsWith("./parts") || path.startsWith("./tools"),
)

expect(renderModuleImports).toEqual([])
})

test("part and tool side-effect barrels cover every registered renderer", () => {
const partsIndex = readFileSync(join(MESSAGE_PART_DIR, "parts", "index.ts"), "utf8")
const toolsIndex = readFileSync(join(MESSAGE_PART_DIR, "tools", "index.ts"), "utf8")
const source = readMessagePartSources()

for (const part of expectedParts) {
expect(source).toContain(`registerPartComponent("${part}"`)
}

for (const tool of expectedTools) {
expect(source).toContain(`name: "${tool}"`)
}

for (const path of ["./compaction-and-divider", "./reasoning", "./text", "./tool"]) {
expect(partsIndex).toContain(`import "${path}"`)
}

for (const path of [
"./read",
"./list",
"./glob",
"./grep",
"./webfetch",
"./websearch",
"./worktree",
"./agent",
"./bash",
"./edit",
"./write",
"./apply-patch",
"./todowrite",
"./question",
"./skill",
]) {
expect(toolsIndex).toContain(`import "${path}"`)
}
})

test("split keeps hidden tools and deferred heavy tool bodies explicit", () => {
const source = readMessagePartSources()
const deferCount = [...source.matchAll(/\bdefer\b/g)].length

expect(source).toContain('export const HIDDEN_TOOLS = new Set(["todowrite"])')
expect(source).toContain('if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit')
expect(source).toContain("defaultOpen={completed()}")
expect(deferCount).toBe(4)
})

test("review hardening keeps routing, clipboard, url, and write guards explicit", () => {
const source = readMessagePartSources()

expect(source).toContain("return path.slice(prefix.length)")
expect(source).toContain("path.search(/\\/session(?:\\/|$)/)")
expect([...source.matchAll(/try \{\n\s+await navigator\.clipboard\.writeText/g)].length).toBe(2)
expect(source).toContain('if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return ""')
expect(source).toContain("getDiagnostics(props.metadata?.diagnostics, props.input.filePath)")
expect(source).toContain("props.input.content != null && path()")
})
39 changes: 31 additions & 8 deletions packages/ui/src/components/message-part-stale.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import { expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"

const COMPONENT_DIR = dirname(fileURLToPath(import.meta.url))

function sourceFiles(dir: string): string[] {
return readdirSync(dir)
.flatMap((name) => {
const full = join(dir, name)
const stat = statSync(full)
if (stat.isDirectory()) return sourceFiles(full)
if (stat.isFile() && /\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name)) return [full]
return []
})
.sort()
}

function readMessagePartSources() {
return [
readFileSync(join(COMPONENT_DIR, "message-part.tsx"), "utf8"),
...sourceFiles(join(COMPONENT_DIR, "message-part")).map((file) => readFileSync(file, "utf8")),
].join("\n")
}

test("assistant part renderers capture item values before passing them to Part", () => {
const source = readFileSync(new URL("./message-part.tsx", import.meta.url), "utf8")
const source = readMessagePartSources()

expect(source).toContain("function latestDefined")
expect(source).not.toContain("<Show when={item()} keyed>")
Expand All @@ -12,7 +35,7 @@ test("assistant part renderers capture item values before passing them to Part",
})

test("tool file accordions account for tool content gap in sticky offset", () => {
const source = readFileSync(new URL("./message-part.tsx", import.meta.url), "utf8")
const source = readMessagePartSources()

expect(source).toContain('style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}')
expect(source).not.toContain('style={{ "--sticky-accordion-offset": "40px" }}')
Expand All @@ -22,14 +45,14 @@ test("tool file accordions account for tool content gap in sticky offset", () =>
// (written by processor cleanup) so the check stays decoupled from the exact
// backend error string. See #419.
test("question tool error renders interrupted variant via metadata.interrupted", () => {
const source = readFileSync(new URL("./message-part.tsx", import.meta.url), "utf8")
const source = readMessagePartSources()

expect(source).toContain('part().tool === "question" && partMetadata()?.interrupted === true')
expect(source).toContain('"ui.messagePart.questions.interrupted"')
})

test("websearch tool errors render localized structured failure copy", () => {
const source = readFileSync(new URL("./message-part.tsx", import.meta.url), "utf8")
const source = readMessagePartSources()

expect(source).toContain('part().tool === "websearch" ? webSearchErrorDisplay(partMetadata(), i18n) : undefined')
expect(source).toContain("error={webSearchError?.error ?? error()}")
Expand All @@ -51,7 +74,7 @@ test("interrupted i18n key exists in zh and en", () => {
// a one-shot snapshot at component setup. Lock the accessor pattern so a
// future "let me memo this once" refactor can't silently break live updates.
test("partMetadata is a fresh accessor over part().state, not a setup-time snapshot", () => {
const source = readFileSync(new URL("./message-part.tsx", import.meta.url), "utf8")
const source = readMessagePartSources()

// Defined as () => …, not const partMetadata = props.part.metadata
expect(source).toContain("const partMetadata = () => toolStateMetadata(part().state)")
Expand All @@ -60,14 +83,14 @@ test("partMetadata is a fresh accessor over part().state, not a setup-time snaps
})

test("synthetic stop tool parts are hidden through reactive metadata", () => {
const source = readFileSync(new URL("./message-part.tsx", import.meta.url), "utf8")
const source = readMessagePartSources()

expect(source).toContain("const hideSyntheticStop = createMemo(")
expect(source).toMatch(/partMetadata\(\)\.diagnostics\?\.loop\?\.loopAction\s*===\s*"stop"/)
})

test("tool part wrapper suppresses both pending questions and synthetic stop tools", () => {
const source = readFileSync(new URL("./message-part.tsx", import.meta.url), "utf8")
const source = readMessagePartSources()

expect(source).toContain("<Show when={!hideQuestion() && !hideSyntheticStop()}>")
})
Expand Down
Loading
Loading