mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f38c3d7059 | ||
|
|
d0876e032f | ||
|
|
858cd5511e | ||
|
|
4f1eefdba9 |
@@ -146,6 +146,36 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
});
|
||||
|
||||
test("opens a remote Markdown image whose URL needs normalization", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Remote image with a mixed-case host",
|
||||
fileName: "remote.png",
|
||||
width: 240,
|
||||
height: 160,
|
||||
});
|
||||
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
|
||||
const url = delivery.url.replace("127.0.0.1", "LOCALHOST");
|
||||
try {
|
||||
delivery.release();
|
||||
const agent = await createSettledMockAgent(workspace, "Remote image lightbox");
|
||||
await emitSettledAssistantImage(workspace.client, agent, image, url);
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
const rendered = page.getByRole("button", { name: image.alt }).first();
|
||||
await expect(rendered).toHaveJSProperty("naturalWidth", image.width);
|
||||
await rendered.click();
|
||||
await expect(page.getByTestId("attachment-lightbox-canvas")).toBeVisible();
|
||||
await page.getByTestId("zoomable-viewport-toolbar").hover();
|
||||
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Close image", exact: true }).click();
|
||||
await expect(page.getByTestId("attachment-lightbox-image")).toHaveCount(0);
|
||||
} finally {
|
||||
await delivery.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reloading a timeline reserves image height without a load-time scroll jump", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { holdNextPreviewLoad } from "../support/helpers/image-preview";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -37,6 +38,15 @@ function editor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
|
||||
async function expectTallImageFrame(frame: Locator): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await frame.boundingBox();
|
||||
return box ? box.height - box.width : 0;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function hasHorizontalOverflow(element: HTMLElement): boolean {
|
||||
return element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
@@ -729,6 +739,10 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "shape.svg"),
|
||||
await readFile(path.resolve(__dirname, "../mobile/fixtures/native-preview.svg")),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
@@ -761,7 +775,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = visibleFilePane.locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const imageCanvas = page.getByTestId("image-file-preview-canvas");
|
||||
const imageCanvas = visibleFilePane.getByTestId("image-file-preview-canvas");
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await imageCanvas.hover();
|
||||
const transformedContent = imageCanvas.locator(":scope > div").first();
|
||||
@@ -774,6 +788,34 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
|
||||
await openWorkspaceFile(page, "shape.svg");
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await expect(image).toBeVisible();
|
||||
await expect(image).toHaveJSProperty("complete", true);
|
||||
await expect(image).toHaveJSProperty("naturalWidth", 240);
|
||||
|
||||
const oldSource = await image.evaluate((element: HTMLImageElement) => ({
|
||||
url: element.currentSrc,
|
||||
width: element.naturalWidth,
|
||||
height: element.naturalHeight,
|
||||
}));
|
||||
const pendingLoad = await holdNextPreviewLoad(visibleFilePane, oldSource.url);
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "shape.svg"),
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="240"><rect width="160" height="240" fill="green"/></svg>',
|
||||
);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(oldSource.url);
|
||||
await pendingLoad.wait();
|
||||
// Native can deliver the old source's queued event to this new listener.
|
||||
await pendingLoad.deliver(oldSource);
|
||||
await expect(imageCanvas).toHaveCount(0);
|
||||
} finally {
|
||||
await pendingLoad.release();
|
||||
}
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await expectTallImageFrame(transformedContent);
|
||||
});
|
||||
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Native SVG preview regression
|
||||
|
||||
Copy `../fixtures/native-preview.svg`, `../fixtures/native-preview-viewbox.svg`, and
|
||||
`../../../assets/images/icon.png` into a workspace on a test daemon. In the Android
|
||||
or iOS app, connect to that daemon, open the workspace's Files tab, and tap
|
||||
`native-preview.svg`. The SVG should show a yellow circle on a green rectangle.
|
||||
|
||||
With that preview open, run:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/agent-device/native-svg-preview.android.ad \
|
||||
--metro-port <port> --artifacts-dir .artifacts/svg-preview
|
||||
```
|
||||
|
||||
Run it again with `native-preview-viewbox.svg` open to cover SVG dimensions
|
||||
inferred from `viewBox`, then with the PNG open to check raster images. Close and
|
||||
reopen the SVG and rerun to cover cached file previews. Use English app language
|
||||
for the zoom button selectors. The scripts are discovered by `npm run test:e2e:mobile`; the iOS counterpart is
|
||||
`native-svg-preview.ios.ad`. They reopen the installed app with its current
|
||||
preview and leave the daemon connection unchanged.
|
||||
|
||||
Before the fix the SVG remains blank and the canvas wait fails. The PNG passes.
|
||||
The replay checks successful native dimension decoding and zoom/reset controls;
|
||||
visually verify the circle and rectangle as well.
|
||||
@@ -0,0 +1,11 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"image-file-preview-canvas\"" 10000
|
||||
wait "role=button label=\"Zoom in\"" 10000
|
||||
press "role=button label=\"Zoom in\""
|
||||
wait "role=button label=\"Reset view\" enabled=true" 10000
|
||||
press "role=button label=\"Reset view\""
|
||||
wait "role=button label=\"Reset view\" enabled=false" 10000
|
||||
close
|
||||
@@ -0,0 +1,11 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"image-file-preview-canvas\"" 10000
|
||||
wait "role=button label=\"Zoom in\"" 10000
|
||||
press "role=button label=\"Zoom in\""
|
||||
wait "role=button label=\"Reset view\" enabled=true" 10000
|
||||
press "role=button label=\"Reset view\""
|
||||
wait "role=button label=\"Reset view\" enabled=false" 10000
|
||||
close
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>
|
||||
|
After Width: | Height: | Size: 164 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>
|
||||
|
After Width: | Height: | Size: 188 B |
@@ -0,0 +1,68 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
function isReady(value: { ready(): boolean }): boolean {
|
||||
return value.ready();
|
||||
}
|
||||
|
||||
/** Replay a queued native load payload after a real file refresh, through the image's load event. */
|
||||
export async function holdNextPreviewLoad(pane: Locator, previousUrl: string) {
|
||||
const pending = await pane.evaluateHandle((container, oldUrl: string) => {
|
||||
let image: HTMLImageElement | null = null;
|
||||
let holding = true;
|
||||
const hold = (event: Event) => {
|
||||
if (holding) event.stopImmediatePropagation();
|
||||
};
|
||||
const observer = new MutationObserver(() => {
|
||||
if (image) return;
|
||||
const candidates = container.querySelectorAll<HTMLImageElement>(
|
||||
'[data-testid="image-file-preview-image"] img',
|
||||
);
|
||||
const next = Array.from(candidates).find((candidate) => candidate.src !== oldUrl);
|
||||
if (!next) return;
|
||||
image = next;
|
||||
image.addEventListener("load", hold, true);
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(container, { subtree: true, childList: true, attributes: true });
|
||||
return {
|
||||
ready: () => image !== null && image.complete && image.naturalWidth > 0,
|
||||
deliver(source: { url: string; width: number; height: number }) {
|
||||
if (!image) throw new Error("No pending preview load");
|
||||
// Expo's web adapter reads these fields to produce the same public onLoad
|
||||
// payload that native queues before React dispatches to the current listener.
|
||||
Object.defineProperties(image, {
|
||||
currentSrc: { configurable: true, value: source.url },
|
||||
naturalWidth: { configurable: true, value: source.width },
|
||||
naturalHeight: { configurable: true, value: source.height },
|
||||
});
|
||||
try {
|
||||
holding = false;
|
||||
image.dispatchEvent(new Event("load"));
|
||||
holding = true;
|
||||
} finally {
|
||||
Reflect.deleteProperty(image, "currentSrc");
|
||||
Reflect.deleteProperty(image, "naturalWidth");
|
||||
Reflect.deleteProperty(image, "naturalHeight");
|
||||
}
|
||||
},
|
||||
release() {
|
||||
holding = false;
|
||||
observer.disconnect();
|
||||
image?.removeEventListener("load", hold, true);
|
||||
image?.dispatchEvent(new Event("load"));
|
||||
},
|
||||
};
|
||||
}, previousUrl);
|
||||
return {
|
||||
async wait() {
|
||||
await expect.poll(() => pending.evaluate(isReady)).toBe(true);
|
||||
},
|
||||
async deliver(source: { url: string; width: number; height: number }) {
|
||||
await pending.evaluate((value, payload) => value.deliver(payload), source);
|
||||
},
|
||||
async release() {
|
||||
await pending.evaluate((value) => value.release());
|
||||
await pending.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Image, type ImageLoadEventData } from "expo-image";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { ViewportFitOptions, ViewportSize } from "./geometry";
|
||||
import { ZoomableViewport } from "./index";
|
||||
import type { ZoomableViewportAction } from "./types";
|
||||
|
||||
const ViewportImage = withUnistyles(Image);
|
||||
|
||||
interface ZoomableImageProps {
|
||||
uri: string;
|
||||
accessibilityLabel?: string;
|
||||
@@ -34,36 +37,27 @@ export function ZoomableImage({
|
||||
testID = "zoomable-image",
|
||||
wheelActivation = "always",
|
||||
}: ZoomableImageProps) {
|
||||
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
|
||||
const resolvedSize = contentSize ?? loadedSize;
|
||||
const source = useMemo(() => ({ uri }), [uri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentSize) return;
|
||||
let active = true;
|
||||
setLoadedSize(null);
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => {
|
||||
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
|
||||
},
|
||||
() => {
|
||||
if (active) onError?.();
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [contentSize, onError, uri]);
|
||||
const source = useMemo(() => ({ uri: normalizeImageUri(uri) }), [uri]);
|
||||
const [loadedImage, setLoadedImage] = useState<{ uri: string; size: ViewportSize } | null>(null);
|
||||
const resolvedSize = contentSize ?? (loadedImage?.uri === source.uri ? loadedImage.size : null);
|
||||
const handleLoad = useCallback(
|
||||
({ source: { url, width, height } }: ImageLoadEventData) => {
|
||||
if (url === source.uri && width > 0 && height > 0) {
|
||||
setLoadedImage({ uri: source.uri, size: { width, height } });
|
||||
}
|
||||
},
|
||||
[source.uri],
|
||||
);
|
||||
|
||||
if (!resolvedSize) {
|
||||
return (
|
||||
<View style={[styles.root, style]} testID={testID}>
|
||||
<Image
|
||||
<ViewportImage
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="image"
|
||||
onError={onError}
|
||||
resizeMode="contain"
|
||||
onLoad={handleLoad}
|
||||
contentFit="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
testID={`${testID}-image`}
|
||||
@@ -85,9 +79,9 @@ export function ZoomableImage({
|
||||
testID={testID}
|
||||
wheelActivation={wheelActivation}
|
||||
>
|
||||
<Image
|
||||
<ViewportImage
|
||||
onError={onError}
|
||||
resizeMode="contain"
|
||||
contentFit="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
testID={`${testID}-image`}
|
||||
@@ -96,6 +90,15 @@ export function ZoomableImage({
|
||||
);
|
||||
}
|
||||
|
||||
// Use the same URL spelling as the decoder while preserving opaque native URIs.
|
||||
function normalizeImageUri(uri: string): string {
|
||||
try {
|
||||
return new URL(uri).href;
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
root: { flex: 1, minHeight: 0 },
|
||||
image: { width: "100%", height: "100%" },
|
||||
|
||||
Reference in New Issue
Block a user