mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
fix(app): keep image load dimensions tied to their source
Ignore a queued load from an earlier URI after a preview refresh. Replay that event ordering in the existing file preview journey, and move the native SVG regression into the Agent Device suite.
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -784,6 +785,29 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
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(page, 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();
|
||||
const tallFrame = await transformedContent.boundingBox();
|
||||
expect(tallFrame!.height).toBeGreaterThan(tallFrame!.width);
|
||||
});
|
||||
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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
|
||||
node packages/app/e2e/mobile/svg-preview.mjs <agent-device-session>
|
||||
```
|
||||
|
||||
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 script drives the existing native session;
|
||||
it does not launch an app or daemon or change its connection.
|
||||
|
||||
Before the fix the SVG remains blank and the canvas wait fails. The PNG passes.
|
||||
The script checks successful native dimension decoding and zoom/reset controls;
|
||||
visually verify the circle and rectangle as well.
|
||||
@@ -1,31 +0,0 @@
|
||||
// Open an SVG file preview in a native app loaded from this checkout first.
|
||||
// node packages/app/e2e/mobile/svg-preview.mjs <agent-device-session>
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session] = process.argv;
|
||||
assert(session, "Pass the agent-device session with an SVG preview open");
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// The canvas only mounts once the native decoder has returned image dimensions.
|
||||
// A file name or an image placeholder alone does not establish a successful load.
|
||||
ad("wait", 'id="image-file-preview-canvas"', "10000");
|
||||
ad("wait", 'role=button label="Zoom in"', "10000");
|
||||
ad("press", 'role=button label="Zoom in"', "--settle");
|
||||
assert.equal(
|
||||
JSON.parse(ad("get", "attrs", 'role=button label="Reset view"', "--json")).data.node.enabled,
|
||||
true,
|
||||
"Zooming enables Reset view",
|
||||
);
|
||||
ad("press", 'role=button label="Reset view"', "--settle");
|
||||
assert.equal(
|
||||
JSON.parse(ad("get", "attrs", 'role=button label="Reset view"', "--json")).data.node.enabled,
|
||||
false,
|
||||
"Reset returns to the fitted view",
|
||||
);
|
||||
console.log("PASS: native SVG decoded, zoomed, and reset");
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, type Page } 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(page: Page, previousUrl: string) {
|
||||
const pending = await page.evaluateHandle((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 = document.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(document.body, { 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -41,8 +41,8 @@ export function ZoomableImage({
|
||||
const resolvedSize = contentSize ?? (loadedImage?.uri === uri ? loadedImage.size : null);
|
||||
const source = useMemo(() => ({ uri }), [uri]);
|
||||
const handleLoad = useCallback(
|
||||
({ source: { width, height } }: ImageLoadEventData) => {
|
||||
if (width > 0 && height > 0) setLoadedImage({ uri, size: { width, height } });
|
||||
({ source: { url, width, height } }: ImageLoadEventData) => {
|
||||
if (url === uri && width > 0 && height > 0) setLoadedImage({ uri, size: { width, height } });
|
||||
},
|
||||
[uri],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user