diff --git a/packages/app/src/agent-stream/turn-footer.browser.test.tsx b/packages/app/src/agent-stream/turn-footer.browser.test.tsx new file mode 100644 index 0000000000..dec9b7fe15 --- /dev/null +++ b/packages/app/src/agent-stream/turn-footer.browser.test.tsx @@ -0,0 +1,94 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import type { TurnTiming } from "@/timeline/turn-time"; +import type { StreamItem } from "@/types/stream"; +import { createNativeStreamStrategy } from "./strategy-native"; +import { CompletedTurnFooterRow } from "./turn-footer"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +// A Sunday, so a turn that ended on Friday shows a weekday timestamp. +const NOW = new Date(2026, 10, 8, 5, 0); +const FRIDAY_NIGHT = new Date(2026, 10, 6, 23, 11); + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("React", React); + vi.useFakeTimers({ toFake: ["Date"], now: NOW }); + host = document.createElement("div"); + host.style.width = "880px"; + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(async () => { + act(() => root.unmount()); + host.remove(); + vi.useRealTimers(); + await i18n.changeLanguage("en"); + vi.unstubAllGlobals(); +}); + +const reply: StreamItem = { + kind: "assistant_message", + id: "reply", + text: "Done.", + timestamp: FRIDAY_NIGHT, +}; + +function renderCompletedFooter(timing: TurnTiming): void { + act(() => + root.render( + , + ), + ); +} + +/** How many painted lines each footer label takes. */ +function paintedLinesByLabel(element: HTMLElement): Record { + const lines: Record = {}; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const text = walker.currentNode.textContent ?? ""; + if (!text.trim()) continue; + const range = document.createRange(); + range.selectNodeContents(walker.currentNode); + lines[text] = new Set(Array.from(range.getClientRects(), (rect) => Math.round(rect.top))).size; + } + return lines; +} + +// The footer shows the duration and swaps to the end timestamp on hover or tap. Neither label +// may be sized by the other: a timestamp with more characters can still paint narrower. +it.each([ + { + language: "en", + timing: { completedAt: FRIDAY_NIGHT, durationMs: 26_000 }, + duration: "Worked for 26s", + }, + // CJK glyphs make the shorter string paint wider in every font. + { + language: "ja", + timing: { completedAt: FRIDAY_NIGHT, durationMs: 59 * 60_000 + 58_000 }, + duration: "作業時間 59m 58s", + }, +])("keeps both timing labels on one line when the chat has room ($language)", async (input) => { + await i18n.changeLanguage(input.language); + + renderCompletedFooter(input.timing); + + expect(paintedLinesByLabel(host)).toEqual({ + [input.duration]: 1, + "Friday 11:11 PM": 1, + }); +}); diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index e07f0b05e7..d213089efa 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -117,3 +117,11 @@ export const Maximize2 = StubIcon; export const Workflow = StubIcon; export const ZoomIn = StubIcon; export const ZoomOut = StubIcon; +export const CheckSquare = StubIcon; +export const Circle = StubIcon; +export const FileSymlink = StubIcon; +export const MessageSquareCode = StubIcon; +export const MicVocal = StubIcon; +export const Scissors = StubIcon; +export const Sparkles = StubIcon; +export const TriangleAlertIcon = StubIcon; diff --git a/packages/app/test-stubs/react-native.mjs b/packages/app/test-stubs/react-native.mjs new file mode 100644 index 0000000000..9c72575d62 --- /dev/null +++ b/packages/app/test-stubs/react-native.mjs @@ -0,0 +1,14 @@ +// react-native-web has no ToastAndroid. The toast host imports it by name for its Android +// branch; Metro and Node tolerate the missing binding, but browser tests load native ESM, +// which rejects the import and fails every module that reaches the toast context. +export * from "react-native-web/dist/index.js"; + +export const ToastAndroid = { + SHORT: 0, + LONG: 1, + TOP: 0, + BOTTOM: 1, + CENTER: 2, + show() {}, + showWithGravity() {}, +}; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 69360fa7ee..386f63305c 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -253,12 +253,12 @@ export default defineConfig({ find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/, replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"), }, - // Point to the ESM build so Vite can transform its imports and apply the + // Point to react-native-web's ESM build so Vite can transform its imports and apply the // react alias below (the CJS build uses require('react') which bypasses - // Vite alias resolution). + // Vite alias resolution). The stub re-exports it and adds what web lacks. { find: "react-native", - replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"), + replacement: path.resolve(__dirname, "test-stubs/react-native.mjs"), }, { find: "react", replacement: resolvePackageEntry("react") }, {