From ea03667a46ec9310642aac68483e789d726d96f4 Mon Sep 17 00:00:00 2001 From: ccarpene-blk Date: Thu, 23 Jul 2026 13:59:35 +0800 Subject: [PATCH] fix(desktop): import avatar URLs client-side --- .../agents/ui/AgentCreationPreview.tsx | 8 +- .../features/profile/avatarUrlImport.test.mjs | 92 ++++++++++++ .../src/features/profile/avatarUrlImport.ts | 133 ++++++++++++++++++ .../src/features/profile/ui/AvatarUpload.tsx | 34 ++++- .../src/features/profile/ui/ProfileAvatar.tsx | 16 ++- .../profile/ui/ProfileAvatarEditor.tsx | 9 +- .../src/features/profile/useAvatarUpload.ts | 40 ++++++ desktop/src/shared/lib/avatarUrl.test.mjs | 64 +++++++++ desktop/src/shared/lib/avatarUrl.ts | 38 +++++ desktop/src/shared/ui/UserAvatar.tsx | 11 +- desktop/src/testing/e2eBridge.ts | 14 ++ desktop/tests/e2e/messaging.spec.ts | 23 ++- .../tests/e2e/onboarding-avatar-skip.spec.ts | 23 ++- .../onboarding-docked-cta-screenshots.spec.ts | 15 +- desktop/tests/e2e/onboarding.spec.ts | 33 +++-- desktop/tests/e2e/profile.spec.ts | 55 +++----- 16 files changed, 537 insertions(+), 71 deletions(-) create mode 100644 desktop/src/features/profile/avatarUrlImport.test.mjs create mode 100644 desktop/src/features/profile/avatarUrlImport.ts create mode 100644 desktop/src/shared/lib/avatarUrl.test.mjs create mode 100644 desktop/src/shared/lib/avatarUrl.ts diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 6c73d14adc..9491f2f889 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -109,6 +109,7 @@ export function AgentCreationPreview({ clearError: clearUploadError, openPicker: openUploadPicker, uploadFile: uploadAvatarFile, + uploadUrl: uploadAvatarUrl, handleFileChange: handleAvatarUploadFileChange, } = useAvatarUpload({ onUploadSuccess: (url) => { @@ -183,8 +184,11 @@ export function AgentCreationPreview({ return; } clearUploadError(); - onSelectAvatar(nextUrl); - setIsAvatarMenuOpen(false); + void uploadAvatarUrl(nextUrl).then((uploaded) => { + if (!uploaded) return; + setAvatarUrlDraft(""); + setIsAvatarMenuOpen(false); + }); } function applyEmojiAvatar(emoji: string, color = selectedColor) { diff --git a/desktop/src/features/profile/avatarUrlImport.test.mjs b/desktop/src/features/profile/avatarUrlImport.test.mjs new file mode 100644 index 0000000000..8158404cfc --- /dev/null +++ b/desktop/src/features/profile/avatarUrlImport.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importAvatarUrl, parseImportUrl } from "./avatarUrlImport.ts"; + +const HASH = "b".repeat(64); + +function response(body, headers = {}) { + return new Response(body, { headers, status: 200 }); +} + +test("parseImportUrl rejects non-http URL schemes", () => { + assert.throws( + () => parseImportUrl("file:///tmp/avatar.png"), + /HTTP\(S\) image URL/, + ); + assert.throws( + () => parseImportUrl("data:image/png,abc"), + /HTTP\(S\) image URL/, + ); +}); + +test("importAvatarUrl fetches client-side bytes and uploads the artefact", async () => { + const fetched = []; + const uploaded = []; + const descriptor = await importAvatarUrl("https://cdn.example/avatar.png", { + relayOrigin: "https://relay.example", + fetchFn: async (url) => { + fetched.push(String(url)); + return response( + new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }), + { + "content-type": "image/png", + "content-length": "3", + }, + ); + }, + uploadFn: async (data, filename) => { + uploaded.push({ data, filename }); + return { + sha256: "hash", + size: data.length, + type: "image/png", + uploaded: Date.now(), + url: `https://relay.example/media/${HASH}.png`, + }; + }, + }); + + assert.deepEqual(fetched, ["https://cdn.example/avatar.png"]); + assert.deepEqual(uploaded, [{ data: [1, 2, 3], filename: "avatar.png" }]); + assert.equal(descriptor.url, `https://relay.example/media/${HASH}.png`); +}); + +test("importAvatarUrl rejects non-image responses before upload", async () => { + let uploaded = false; + await assert.rejects( + () => + importAvatarUrl("https://cdn.example/not-image.txt", { + relayOrigin: "https://relay.example", + fetchFn: async () => + response("hello", { + "content-type": "text/plain", + "content-length": "5", + }), + uploadFn: async () => { + uploaded = true; + throw new Error("unreachable"); + }, + }), + /PNG, JPG, GIF, or WebP/, + ); + assert.equal(uploaded, false); +}); + +test("importAvatarUrl refuses oversized images", async () => { + await assert.rejects( + () => + importAvatarUrl("https://cdn.example/avatar.png", { + relayOrigin: "https://relay.example", + fetchFn: async () => + response(new Blob(["tiny"], { type: "image/png" }), { + "content-type": "image/png", + "content-length": String(11 * 1024 * 1024), + }), + uploadFn: async () => { + throw new Error("unreachable"); + }, + }), + /too large/, + ); +}); diff --git a/desktop/src/features/profile/avatarUrlImport.ts b/desktop/src/features/profile/avatarUrlImport.ts new file mode 100644 index 0000000000..255072867a --- /dev/null +++ b/desktop/src/features/profile/avatarUrlImport.ts @@ -0,0 +1,133 @@ +import { uploadMediaBytes, type BlobDescriptor } from "@/shared/api/tauri"; +import { isRelayHostedAvatarUrl } from "@/shared/lib/avatarUrl"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; + +const MAX_IMPORTED_AVATAR_BYTES = 10 * 1024 * 1024; +const IMPORT_TIMEOUT_MS = 15_000; +const ACCEPTED_IMPORTED_AVATAR_TYPES = new Set([ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); + +type UploadFn = (data: number[], filename?: string) => Promise; + +type FetchFn = typeof fetch; + +export type ImportAvatarUrlDependencies = { + fetchFn?: FetchFn; + uploadFn?: UploadFn; + relayOrigin: string | null; +}; + +export async function importAvatarUrl( + rawUrl: string, + { + fetchFn = fetch, + relayOrigin, + uploadFn = uploadMediaBytes, + }: ImportAvatarUrlDependencies, +): Promise { + const sourceUrl = parseImportUrl(rawUrl); + const fetchUrl = isRelayHostedAvatarUrl(sourceUrl, relayOrigin) + ? rewriteRelayUrl(sourceUrl) + : sourceUrl; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), IMPORT_TIMEOUT_MS); + try { + const response = await fetchFn(fetchUrl, { + credentials: "omit", + referrerPolicy: "no-referrer", + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Could not fetch avatar image (${response.status}).`); + } + assertImportImageHeaders(response.headers); + const blob = await response.blob(); + assertImportImageBlob(blob); + const buffer = await blob.arrayBuffer(); + const uploaded = await uploadFn( + [...new Uint8Array(buffer)], + filenameForImportedAvatar(sourceUrl, blob.type), + ); + if (!isAcceptedAvatarImageType(uploaded.type)) { + throw new Error("Choose a PNG, JPG, GIF, or WebP image."); + } + return uploaded; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("Avatar image fetch timed out."); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +export function parseImportUrl(rawUrl: string): string { + const trimmed = rawUrl.trim(); + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error("Enter a valid HTTP(S) image URL."); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("Enter a valid HTTP(S) image URL."); + } + return parsed.toString(); +} + +function assertImportImageHeaders(headers: Headers): void { + const contentType = headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType && !isAcceptedAvatarImageType(contentType)) { + throw new Error("Choose a PNG, JPG, GIF, or WebP image."); + } + const contentLength = Number(headers.get("content-length") ?? "0"); + if (contentLength > MAX_IMPORTED_AVATAR_BYTES) { + throw new Error("Avatar image is too large."); + } +} + +function assertImportImageBlob(blob: Blob): void { + if (blob.size === 0) { + throw new Error("Avatar image is empty."); + } + if (blob.size > MAX_IMPORTED_AVATAR_BYTES) { + throw new Error("Avatar image is too large."); + } + if (blob.type && !isAcceptedAvatarImageType(blob.type)) { + throw new Error("Choose a PNG, JPG, GIF, or WebP image."); + } +} + +function isAcceptedAvatarImageType(contentType: string): boolean { + return ACCEPTED_IMPORTED_AVATAR_TYPES.has( + contentType.toLowerCase().split(";")[0]?.trim() ?? "", + ); +} + +function filenameForImportedAvatar( + sourceUrl: string, + contentType: string, +): string { + const pathSegment = new URL(sourceUrl).pathname.split("/").pop() ?? ""; + const cleanName = pathSegment.replace(/[^\w.-]/gu, "_"); + if (/\.(?:png|jpe?g|gif|webp)$/iu.test(cleanName)) return cleanName; + return `avatar.${extensionForImageType(contentType)}`; +} + +function extensionForImageType(contentType: string): string { + switch (contentType.toLowerCase()) { + case "image/gif": + return "gif"; + case "image/jpeg": + return "jpg"; + case "image/webp": + return "webp"; + default: + return "png"; + } +} diff --git a/desktop/src/features/profile/ui/AvatarUpload.tsx b/desktop/src/features/profile/ui/AvatarUpload.tsx index 384c78927e..70386e2df4 100644 --- a/desktop/src/features/profile/ui/AvatarUpload.tsx +++ b/desktop/src/features/profile/ui/AvatarUpload.tsx @@ -31,6 +31,8 @@ export function AvatarUpload({ testIdPrefix = "avatar", }: AvatarUploadProps) { const [isDragging, setIsDragging] = React.useState(false); + const [urlDraft, setUrlDraft] = React.useState(avatarUrl); + const isUrlInputFocusedRef = React.useRef(false); const onUploadSuccess = React.useCallback( (url: string) => { @@ -46,6 +48,7 @@ export function AvatarUpload({ clearError, openPicker, handleFileChange, + uploadUrl, } = useAvatarUpload({ onUploadSuccess }); React.useEffect(() => { @@ -54,6 +57,19 @@ export function AvatarUpload({ const isInputDisabled = disabled || isUploading; + React.useEffect(() => { + if (!isUrlInputFocusedRef.current) setUrlDraft(avatarUrl); + }, [avatarUrl]); + + const applyUrl = React.useCallback(() => { + const nextUrl = urlDraft.trim(); + if (nextUrl.length === 0 || isInputDisabled) return; + clearError(); + void uploadUrl(nextUrl).then((uploaded) => { + if (uploaded) setUrlDraft(""); + }); + }, [clearError, isInputDisabled, uploadUrl, urlDraft]); + const handleDrop = React.useCallback( (e: React.DragEvent) => { e.preventDefault(); @@ -185,12 +201,26 @@ export function AvatarUpload({ data-testid={`${testIdPrefix}-url`} disabled={isInputDisabled} id={`${testIdPrefix}-url`} + onBlur={() => { + isUrlInputFocusedRef.current = false; + applyUrl(); + }} onChange={(event) => { clearError(); - onUrlChange(event.target.value); + setUrlDraft(event.target.value); + }} + onFocus={() => { + isUrlInputFocusedRef.current = true; + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + applyUrl(); + } }} placeholder="https://example.com/avatar.png" - value={avatarUrl} + type="url" + value={urlDraft} />

diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index 3153fb4be1..a43a2d3210 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -3,9 +3,10 @@ import { UserRound } from "lucide-react"; import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore"; import { parseAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar"; +import { resolveAvatarImageSrc } from "@/shared/lib/avatarUrl"; import { cn } from "@/shared/lib/cn"; import { getInitials } from "@/shared/lib/initials"; -import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar"; import { Spinner } from "@/shared/ui/spinner"; @@ -31,6 +32,7 @@ export function ProfileAvatar({ testId, }: ProfileAvatarProps) { const initials = getInitials(label); + const relayOrigin = useRelayOrigin(); const presentation = useAvatarPresentation(avatarUrl); const presentedAvatarUrl = presentation?.displayUrl ?? avatarUrl; @@ -46,15 +48,19 @@ export function ProfileAvatar({ // Compute the live (proxied) source. Failures are tracked per resolved URL so // the poster and hover animation can recover independently. - const liveSrc = baseUrl ? rewriteRelayUrl(baseUrl) : null; + const liveSrc = baseUrl ? resolveAvatarImageSrc(baseUrl, relayOrigin) : null; + const isBlockedAvatarUrl = + baseUrl !== undefined && baseUrl !== null && liveSrc === null; const [failedSrc, setFailedSrc] = React.useState(null); const liveFailed = liveSrc !== null && failedSrc === liveSrc; // When the relay is unreachable the proxied avatar URL 404s/times out; fall // back to the locally cached data URL instead of dropping to initials. - const src = liveFailed - ? (avatarDataUrl ?? undefined) - : (liveSrc ?? avatarDataUrl ?? undefined); + const src = isBlockedAvatarUrl + ? undefined + : liveFailed + ? (avatarDataUrl ?? undefined) + : (liveSrc ?? avatarDataUrl ?? undefined); const shouldShowFallback = src === undefined || (!animated && liveFailed); return ( diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx index 9e7901fffc..9376837e15 100644 --- a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx @@ -185,6 +185,7 @@ export function ProfileAvatarEditor({ isUploading, openPicker, uploadFile, + uploadUrl, } = useAvatarUpload(uploadPreviewLifecycle); const isInputDisabled = disabled || isUploading || isAnimatedApplyPending; const handleAnimatedApply = React.useCallback( @@ -379,14 +380,16 @@ export function ProfileAvatarEditor({ clearUploadError(); onUploadedAvatarChange?.(null); - onUrlChange(nextUrl); + void uploadUrl(nextUrl).then((uploaded) => { + if (uploaded) setUrlDraft(""); + }); hasUserEditedUrlDraftRef.current = false; updateMode("image"); }, [ clearUploadError, isInputDisabled, onUploadedAvatarChange, - onUrlChange, + uploadUrl, updateMode, urlDraft, ]); @@ -698,8 +701,6 @@ export function ProfileAvatarEditor({ clearUploadError(); hasUserEditedUrlDraftRef.current = true; setUrlDraft(event.target.value); - onUploadedAvatarChange?.(null); - onUrlChange(event.target.value); }} onFocus={() => { isUrlInputFocusedRef.current = true; diff --git a/desktop/src/features/profile/useAvatarUpload.ts b/desktop/src/features/profile/useAvatarUpload.ts index f3477c494e..748116bc73 100644 --- a/desktop/src/features/profile/useAvatarUpload.ts +++ b/desktop/src/features/profile/useAvatarUpload.ts @@ -1,7 +1,13 @@ import * as React from "react"; import { flushSync } from "react-dom"; +import { + importAvatarUrl, + parseImportUrl, +} from "@/features/profile/avatarUrlImport"; import { uploadMediaBytes } from "@/shared/api/tauri"; +import { isRelayHostedAvatarUrl } from "@/shared/lib/avatarUrl"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; const AVATAR_IMAGE_TYPES = [ "image/gif", @@ -23,6 +29,7 @@ type UseAvatarUploadReturn = { clearError: () => void; openPicker: () => void; uploadFile: (file: File) => Promise; + uploadUrl: (url: string) => Promise; handleFileChange: (event: React.ChangeEvent) => void; }; @@ -32,6 +39,7 @@ export function useAvatarUpload({ onUploadSuccess, }: UseAvatarUploadOptions): UseAvatarUploadReturn { const inputRef = React.useRef(null); + const relayOrigin = useRelayOrigin(); const [isUploading, setIsUploading] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(null); @@ -82,6 +90,37 @@ export function useAvatarUpload({ [onUploadSettled, onUploadStart, onUploadSuccess], ); + const uploadUrl = React.useCallback( + async (url: string) => { + flushSync(() => { + setIsUploading(true); + setErrorMessage(null); + }); + + try { + const normalizedUrl = parseImportUrl(url); + if (isRelayHostedAvatarUrl(normalizedUrl, relayOrigin)) { + onUploadSuccess(normalizedUrl); + return true; + } + const uploaded = await importAvatarUrl(url, { relayOrigin }); + onUploadSuccess(uploaded.url); + return true; + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Could not import that avatar image.", + ); + return false; + } finally { + setIsUploading(false); + onUploadSettled?.(); + } + }, + [onUploadSettled, onUploadSuccess, relayOrigin], + ); + const handleFileChange = React.useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -103,6 +142,7 @@ export function useAvatarUpload({ clearError, openPicker, uploadFile, + uploadUrl, handleFileChange, }; } diff --git a/desktop/src/shared/lib/avatarUrl.test.mjs b/desktop/src/shared/lib/avatarUrl.test.mjs new file mode 100644 index 0000000000..e08f2fdacd --- /dev/null +++ b/desktop/src/shared/lib/avatarUrl.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isRelayHostedAvatarUrl, resolveAvatarImageSrc } from "./avatarUrl.ts"; + +const HASH = "a".repeat(64); +const RELAY_ORIGIN = "https://relay.example"; + +test("isRelayHostedAvatarUrl accepts image media on the active relay", () => { + assert.equal( + isRelayHostedAvatarUrl(`${RELAY_ORIGIN}/media/${HASH}.png`, RELAY_ORIGIN), + true, + ); + assert.equal( + isRelayHostedAvatarUrl( + `${RELAY_ORIGIN}/media/${HASH}.thumb.jpg`, + RELAY_ORIGIN, + ), + true, + ); +}); + +test("isRelayHostedAvatarUrl rejects external media and non-image media", () => { + assert.equal( + isRelayHostedAvatarUrl( + `https://nostr.build/media/${HASH}.png`, + RELAY_ORIGIN, + ), + false, + ); + assert.equal( + isRelayHostedAvatarUrl(`${RELAY_ORIGIN}/media/${HASH}.mp4`, RELAY_ORIGIN), + false, + ); + assert.equal( + isRelayHostedAvatarUrl(`${RELAY_ORIGIN}/other/${HASH}.png`, RELAY_ORIGIN), + false, + ); +}); + +test("resolveAvatarImageSrc fails closed for unresolved relay origin", () => { + assert.equal( + resolveAvatarImageSrc(`${RELAY_ORIGIN}/media/${HASH}.png`, null), + null, + ); +}); + +test("resolveAvatarImageSrc preserves inline previews and blocks external URLs", () => { + assert.equal( + resolveAvatarImageSrc("blob:local-preview", null), + "blob:local-preview", + ); + assert.equal( + resolveAvatarImageSrc("data:image/svg+xml,%3Csvg%3E%3C/svg%3E", null), + "data:image/svg+xml,%3Csvg%3E%3C/svg%3E", + ); + assert.equal( + resolveAvatarImageSrc( + `https://nostr.build/media/${HASH}.png`, + RELAY_ORIGIN, + ), + null, + ); +}); diff --git a/desktop/src/shared/lib/avatarUrl.ts b/desktop/src/shared/lib/avatarUrl.ts new file mode 100644 index 0000000000..d3d506c554 --- /dev/null +++ b/desktop/src/shared/lib/avatarUrl.ts @@ -0,0 +1,38 @@ +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; + +const AVATAR_MEDIA_RE = + /^https?:\/\/[^/]+\/media\/[\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp)(?:\?.*)?$/iu; + +const INLINE_AVATAR_RE = /^(?:blob:|data:image\/)/iu; + +function canonicalOrigin(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +export function isInlineAvatarUrl(url: string | null | undefined): boolean { + return INLINE_AVATAR_RE.test(url?.trim() ?? ""); +} + +export function isRelayHostedAvatarUrl( + url: string | null | undefined, + relayOrigin: string | null, +): boolean { + const trimmed = url?.trim(); + if (!trimmed || !relayOrigin || !AVATAR_MEDIA_RE.test(trimmed)) return false; + return canonicalOrigin(trimmed) === relayOrigin; +} + +export function resolveAvatarImageSrc( + url: string | null | undefined, + relayOrigin: string | null, +): string | null { + const trimmed = url?.trim(); + if (!trimmed) return null; + if (isInlineAvatarUrl(trimmed)) return trimmed; + if (!isRelayHostedAvatarUrl(trimmed, relayOrigin)) return null; + return rewriteRelayUrl(trimmed); +} diff --git a/desktop/src/shared/ui/UserAvatar.tsx b/desktop/src/shared/ui/UserAvatar.tsx index 1ef1a9b815..2b3fa85e4b 100644 --- a/desktop/src/shared/ui/UserAvatar.tsx +++ b/desktop/src/shared/ui/UserAvatar.tsx @@ -1,9 +1,10 @@ import * as React from "react"; import { parseAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar"; +import { resolveAvatarImageSrc } from "@/shared/lib/avatarUrl"; import { cn } from "@/shared/lib/cn"; import { getInitials } from "@/shared/lib/initials"; -import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar"; type UserAvatarSize = "xs" | "sm" | "md"; @@ -32,14 +33,18 @@ export function UserAvatar({ testId, }: UserAvatarProps) { const initials = getInitials(displayName); + const relayOrigin = useRelayOrigin(); // Animated avatars show their static poster frame until hovered, then play // the animation. const animated = parseAnimatedAvatarUrl(avatarUrl); const [isHovered, setIsHovered] = React.useState(false); const src = animated - ? rewriteRelayUrl(isHovered ? animated.animationUrl : animated.posterUrl) + ? resolveAvatarImageSrc( + isHovered ? animated.animationUrl : animated.posterUrl, + relayOrigin, + ) : avatarUrl - ? rewriteRelayUrl(avatarUrl) + ? resolveAvatarImageSrc(avatarUrl, relayOrigin) : null; return ( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 33e9e02ba1..01b2b42744 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10448,6 +10448,20 @@ export function maybeInstallE2eTauriMocks() { case "pick_and_upload_image": return (await resolveMockUploadDescriptors(activeConfig))[0] ?? null; case "upload_media_bytes": + if (activeConfig?.mock?.uploadDescriptors === undefined) { + const filename = (payload as { filename?: string | null }).filename; + if (filename && /\.(?:png|jpe?g|gif|webp)$/iu.test(filename)) { + const hash = "b".repeat(64); + return { + url: `${getRelayHttpUrl(activeConfig)}/media/${hash}.png`, + sha256: hash, + size: 1234, + type: "image/png", + uploaded: Math.floor(Date.now() / 1000), + filename, + }; + } + } return (await resolveMockUploadDescriptors(activeConfig))[0]; case "fetch_media_bytes": { // The real command fetches relay media through Rust reqwest and diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 2487dbbf9c..649a150ac2 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1,9 +1,19 @@ -import { expect, test, type Locator } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; import { openSettings } from "../helpers/settings"; +async function mockAvatarImageFetch(page: Page, url: string) { + await page.route(url, (route) => + route.fulfill({ + body: Buffer.from("mock-avatar-png"), + contentType: "image/png", + status: 200, + }), + ); +} + async function expectThreadReplyUnobscured(row: Locator) { await expect .poll(async () => @@ -646,13 +656,16 @@ test("shows your avatar on your own message when profile avatar is set", async ( page, }) => { const message = `Avatar message ${Date.now()}`; - const avatarUrl = - 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E'; + const avatarUrl = `https://example.com/message-avatar-${Date.now()}.png`; + await mockAvatarImageFetch(page, avatarUrl); await page.goto("/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); - await page.getByTestId("profile-avatar-url").fill(avatarUrl); + const input = page.getByTestId("profile-avatar-url"); + await input.fill(avatarUrl); + await input.press("Enter"); + await expect(input).toHaveValue(""); await page.getByTestId("profile-avatar-done").click(); await page.getByTestId("settings-back-to-app").click(); @@ -666,7 +679,7 @@ test("shows your avatar on your own message when profile avatar is set", async ( await expect(lastMessage).toContainText(message); await expect(lastMessage.getByTestId("message-avatar-image")).toHaveAttribute( "src", - avatarUrl, + /\/media\/[\da-f]{64}\.png$/, ); }); diff --git a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts index 0ee08e2f01..3f8e1dd0cb 100644 --- a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts +++ b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -11,6 +11,16 @@ const BLANK_TYLER_IDENTITY = { const SHOTS = "test-results/screenshots-onboarding"; +async function mockAvatarImageFetch(page: Page, url: string) { + await page.route(url, (route) => + route.fulfill({ + body: Buffer.from("mock-avatar-png"), + contentType: "image/png", + status: 200, + }), + ); +} + test("avatar step always shows Skip for now button without an error", async ({ page, }) => { @@ -69,10 +79,13 @@ test("avatar Next button still requires an avatar to be chosen", async ({ // Next is disabled until an avatar is set. await expect(page.getByTestId("onboarding-next")).toBeDisabled(); - // Once an avatar URL is provided, Next enables. - await page - .getByTestId("onboarding-avatar-url") - .fill("https://example.com/avatar.png"); + // Once an avatar URL is imported, Next enables. + const avatarUrl = "https://example.com/avatar.png"; + await mockAvatarImageFetch(page, avatarUrl); + const input = page.getByTestId("onboarding-avatar-url"); + await input.fill(avatarUrl); + await input.press("Enter"); + await expect(input).toHaveValue(""); await expect(page.getByTestId("onboarding-next")).toBeEnabled(); }); diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index dbb8a92a1f..4da9c7339c 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -127,9 +127,18 @@ test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => { await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); - await page - .getByTestId("onboarding-avatar-url") - .fill("https://example.com/onboarding-avatar.png"); + const avatarUrl = "https://example.com/onboarding-avatar.png"; + await page.route(avatarUrl, (route) => + route.fulfill({ + body: Buffer.from("mock-avatar-png"), + contentType: "image/png", + status: 200, + }), + ); + const input = page.getByTestId("onboarding-avatar-url"); + await input.fill(avatarUrl); + await input.press("Enter"); + await expect(input).toHaveValue(""); await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/05-avatar.png` }); }); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index c3c716bec3..70cc490c12 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -521,12 +521,31 @@ async function expectIncompleteOnboarding(page: Page) { await expect(page.getByTestId("onboarding-display-name")).toHaveValue(""); } +async function mockAvatarImageFetch(page: Page, url: string) { + await page.route(url, (route) => + route.fulfill({ + body: Buffer.from("mock-avatar-png"), + contentType: "image/png", + status: 200, + }), + ); +} + +async function importOnboardingAvatarUrl(page: Page, url: string) { + await mockAvatarImageFetch(page, url); + const input = page.getByTestId("onboarding-avatar-url"); + await input.fill(url); + await input.press("Enter"); + await expect(input).toHaveValue(""); +} + async function completeProfileOnboarding(page: Page) { await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); - await page - .getByTestId("onboarding-avatar-url") - .fill("https://example.com/onboarding-avatar.png"); + await importOnboardingAvatarUrl( + page, + "https://example.com/onboarding-avatar.png", + ); await page.getByTestId("onboarding-next").click(); } @@ -1999,9 +2018,7 @@ test("avatar step accepts an avatar URL before completing onboarding", async ({ await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); - await page - .getByTestId("onboarding-avatar-url") - .fill("https://example.com/morty.png"); + await importOnboardingAvatarUrl(page, "https://example.com/morty.png"); const preview = page.getByTestId("onboarding-avatar-preview"); await expect(preview).toBeVisible(); @@ -2024,9 +2041,7 @@ test("failed avatar saves can continue without saving the avatar", async ({ await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); - await page - .getByTestId("onboarding-avatar-url") - .fill("https://example.com/morty.png"); + await importOnboardingAvatarUrl(page, "https://example.com/morty.png"); await page.evaluate(() => { const testWindow = window as Window & { __BUZZ_E2E__?: { mock?: { profileUpdateError?: string } }; diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index a945d159b2..4a68d99b8a 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -44,6 +44,24 @@ async function waitForAvatarEditorToClose(page: Page) { await expect(page.getByTestId("profile-avatar-editor-shell")).toHaveCount(0); } +async function mockAvatarImageFetch(page: Page, url: string) { + await page.route(url, (route) => + route.fulfill({ + body: Buffer.from("mock-avatar-png"), + contentType: "image/png", + status: 200, + }), + ); +} + +async function importProfileAvatarUrl(page: Page, url: string) { + await mockAvatarImageFetch(page, url); + const input = page.getByTestId("profile-avatar-url"); + await input.fill(url); + await input.press("Enter"); + await expect(input).toHaveValue(""); +} + async function waitForReactEffects(page: Page) { await page.evaluate( () => @@ -243,7 +261,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => { await expect(page.getByTestId("profile-about-value")).toHaveText(about); await page.getByTestId("profile-avatar-edit").click(); - await page.getByTestId("profile-avatar-url").fill(avatarUrl); + await importProfileAvatarUrl(page, avatarUrl); await page.getByTestId("profile-avatar-done").click(); await waitForAvatarEditorToClose(page); @@ -498,19 +516,6 @@ test("highlights the avatar drop target while dragging an image", async ({ }); test("uploads local profile avatar files before saving", async ({ page }) => { - const uploadedAvatarUrl = "https://mock.relay/media/avatar-profile.png"; - await installMockBridge(page, { - uploadDescriptors: [ - { - filename: "avatar-profile.png", - sha256: "b".repeat(64), - size: 553432, - type: "image/png", - uploaded: 1_779_900_000, - url: uploadedAvatarUrl, - }, - ], - }); await page.goto("/"); await openSettings(page, "profile"); @@ -527,26 +532,10 @@ test("uploads local profile avatar files before saving", async ({ page }) => { await page.getByTestId("profile-avatar-edit").click(); await expect(page.getByTestId("profile-avatar-url")).toHaveValue(""); - const pastedAvatarUrl = await page.evaluate( - () => new URL("/buzz.svg", window.location.href).href, - ); - await page.getByTestId("profile-avatar-url").click(); - await page.keyboard.insertText(pastedAvatarUrl); - await expect(page.getByTestId("profile-avatar-url")).toHaveValue( - pastedAvatarUrl, - ); + const pastedAvatarUrl = "https://example.com/pasted-profile-avatar.png"; + await importProfileAvatarUrl(page, pastedAvatarUrl); await page.getByTestId("profile-avatar-done").click(); await waitForAvatarEditorToClose(page); - await page.getByTestId("profile-avatar-edit").click(); - await expect(page.getByTestId("profile-avatar-url")).toHaveValue(""); - await page.getByTestId("profile-avatar-url").fill(""); - await page.getByTestId("profile-avatar-done").click(); - await expect( - page.getByTestId("profile-avatar-preview").locator("img"), - ).toHaveCount(1); - await waitForAvatarEditorToClose(page); - await page.getByTestId("profile-avatar-edit").click(); - await expect(page.getByTestId("profile-avatar-url")).toHaveValue(""); await expect .poll(() => @@ -593,7 +582,7 @@ test("reveals emoji background colors only after choosing an emoji", async ({ await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); - await page.getByTestId("profile-avatar-url").fill(imageAvatarUrl); + await importProfileAvatarUrl(page, imageAvatarUrl); await page.getByTestId("profile-avatar-done").click(); await waitForAvatarEditorToClose(page);