test(ui): stabilize attachment URL lifecycle proof

Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-b547-7788-a916-d4a94cbd3e3b
This commit is contained in:
Amp
2026-08-21 09:42:46 +00:00
parent 5d8cd4c819
commit f3a63fe191
2 changed files with 202 additions and 113 deletions
@@ -0,0 +1,202 @@
import { expect, it } from "vitest";
import {
controlUiSessionPath,
createNewSessionPageE2eSuite,
installMockGateway,
pastePng,
waitForCommittedNewSessionDraft,
} from "./new-session-page.test-support.ts";
type AttachmentUrlProof = {
created: string[];
revoked: string[];
deferNextRead: boolean;
deferredReads: number;
releaseDeferredReads: () => void;
};
declare global {
interface Window {
attachmentUrlProof: AttachmentUrlProof;
}
}
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("releases pasted image previews after remove, reset, restored removal, and success", async () => {
await suite.withPage(
{ locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1280 } },
async ({ page }) => {
await page.addInitScript(() => {
const createObjectURL = URL.createObjectURL.bind(URL);
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
const readAsDataURL = Object.getOwnPropertyDescriptor(
FileReader.prototype,
"readAsDataURL",
)?.value as FileReader["readAsDataURL"];
const deferredReads: Array<{ blob: Blob; reader: FileReader }> = [];
const proof: AttachmentUrlProof = {
created: [],
revoked: [],
deferNextRead: false,
deferredReads: 0,
releaseDeferredReads: () => {},
};
window.attachmentUrlProof = proof;
URL.createObjectURL = (blob: Blob) => {
const url = createObjectURL(blob);
proof.created.push(url);
return url;
};
URL.revokeObjectURL = (url: string) => {
proof.revoked.push(url);
revokeObjectURL(url);
};
FileReader.prototype.readAsDataURL = function (blob: Blob) {
if (!proof.deferNextRead) {
return readAsDataURL.call(this, blob);
}
proof.deferNextRead = false;
deferredReads.push({ blob, reader: this });
proof.deferredReads = deferredReads.length;
};
proof.releaseDeferredReads = () => {
for (const { blob, reader } of deferredReads.splice(0)) {
readAsDataURL.call(reader, blob);
}
proof.deferredReads = 0;
};
});
await installMockGateway(page, {
methodResponses: {
"agents.list": {
defaultId: "main",
mainKey: "main",
scope: "agent",
agents: [
{ id: "main", name: "Main" },
{ id: "writer", name: "Writer" },
],
},
"sessions.create": { key: "agent:main:preview-cleanup", runStarted: true },
},
});
const readObjectUrlState = () =>
page.evaluate(() => {
const proof = window.attachmentUrlProof;
const created = new Set(proof.created);
const revoked = new Set(proof.revoked);
return {
active: proof.created.filter((url) => !revoked.has(url)).length,
created: proof.created.length,
duplicateRevocations: proof.revoked.length - revoked.size,
unknownRevocations: proof.revoked.filter((url) => !created.has(url)).length,
};
});
const expectActiveObjectUrls = async (active: number) => {
await expect
.poll(async () => {
const { created: _created, ...state } = await readObjectUrlState();
return state;
})
.toEqual({ active, duplicateRevocations: 0, unknownRevocations: 0 });
};
const navigate = (routeId: string, search = "") =>
page.evaluate(
({ targetRouteId, targetSearch }) => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
context: {
navigate: (routeId: string, options?: { search?: string }) => void;
};
};
};
if (!app.runtime) {
throw new Error("OpenClaw application runtime is unavailable");
}
app.runtime.context.navigate(targetRouteId, { search: targetSearch });
},
{ targetRouteId: routeId, targetSearch: search },
);
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await page.getByRole("button", { name: "Remove attachment" }).click();
await expectActiveObjectUrls(0);
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await waitForCommittedNewSessionDraft(page, "", 1);
const agentDropdown = page.locator(".new-session-page__select--agent wa-dropdown");
await page.locator(".new-session-page__select--agent .agent-select__trigger").click();
await expect
.poll(() =>
agentDropdown.evaluate(
(dropdown) => (dropdown as HTMLElement & { open: boolean }).open,
),
)
.toBe(true);
await navigate("new-session", "?agent=main&catalog=missing");
await expect
.poll(() =>
page.evaluate(
() =>
(
document.querySelector(".new-session-page__select--agent wa-dropdown") as
| (HTMLElement & { open: boolean })
| null
)?.open ?? false,
),
)
.toBe(false);
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(0);
await expectActiveObjectUrls(0);
await page.evaluate(() => {
window.attachmentUrlProof.deferNextRead = true;
});
await navigate("new-session");
await composer.waitFor();
await expect
.poll(() => page.evaluate(() => window.attachmentUrlProof.deferredReads))
.toBe(1);
const createdBeforeHydration = (await readObjectUrlState()).created;
await page.evaluate(() => window.attachmentUrlProof.releaseDeferredReads());
await expect.poll(readObjectUrlState).toEqual({
active: 1,
created: createdBeforeHydration + 1,
duplicateRevocations: 0,
unknownRevocations: 0,
});
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(1);
await page.getByRole("button", { name: "Remove attachment" }).click();
await expectActiveObjectUrls(0);
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await waitForCommittedNewSessionDraft(page, "", 1);
await navigate("chat");
await page.waitForURL((url) => url.pathname.endsWith("/chat"));
await expectActiveObjectUrls(1);
await navigate("new-session");
await composer.waitFor();
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(1);
await page.getByRole("button", { name: "Remove attachment" }).click();
await expectActiveObjectUrls(0);
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await expectActiveObjectUrls(1);
await page.getByRole("button", { name: "Start session" }).click();
await page.waitForURL(
(url) => url.pathname === controlUiSessionPath("agent:main:preview-cleanup"),
);
await expectActiveObjectUrls(0);
},
);
});
});
@@ -665,119 +665,6 @@ suite.define(() => {
});
});
it("releases pasted image previews after remove, reset, restored removal, and success", async () => {
await withNewSessionPage(async (page) => {
await page.addInitScript(() => {
const createObjectURL = URL.createObjectURL.bind(URL);
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
const proof = { created: 0, revoked: 0 };
(globalThis as unknown as { attachmentUrlProof: typeof proof }).attachmentUrlProof = proof;
URL.createObjectURL = (blob: Blob) => {
proof.created += 1;
return createObjectURL(blob);
};
URL.revokeObjectURL = (url: string) => {
proof.revoked += 1;
revokeObjectURL(url);
};
});
await installMockGateway(page, {
methodResponses: {
"agents.list": {
defaultId: "main",
mainKey: "main",
scope: "agent",
agents: [
{ id: "main", name: "Main" },
{ id: "writer", name: "Writer" },
],
},
"sessions.create": { key: "agent:main:preview-cleanup", runStarted: true },
},
});
const proof = () =>
page.evaluate(
() =>
(globalThis as unknown as { attachmentUrlProof: { created: number; revoked: number } })
.attachmentUrlProof,
);
const navigate = (routeId: string, search = "") =>
page.evaluate(
({ targetRouteId, targetSearch }) => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
context: {
navigate: (routeId: string, options?: { search?: string }) => void;
};
};
};
if (!app.runtime) {
throw new Error("OpenClaw application runtime is unavailable");
}
app.runtime.context.navigate(targetRouteId, { search: targetSearch });
},
{ targetRouteId: routeId, targetSearch: search },
);
await page.goto(`${suite.server.baseUrl}new`);
const composer = page.locator(".new-session-page__message");
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await page.getByRole("button", { name: "Remove attachment" }).click();
await expect.poll(async () => (await proof()).revoked).toBe(1);
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await waitForCommittedNewSessionDraft(page, "", 1);
const agentDropdown = page.locator(".new-session-page__select--agent wa-dropdown");
await page.locator(".new-session-page__select--agent .agent-select__trigger").click();
await expect
.poll(() =>
agentDropdown.evaluate((dropdown) => (dropdown as HTMLElement & { open: boolean }).open),
)
.toBe(true);
await navigate("new-session", "?agent=main&catalog=missing");
await expect
.poll(() =>
page.evaluate(
() =>
(
document.querySelector(".new-session-page__select--agent wa-dropdown") as
| (HTMLElement & { open: boolean })
| null
)?.open ?? false,
),
)
.toBe(false);
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(0);
await expect.poll(async () => (await proof()).revoked).toBe(2);
await navigate("new-session");
await composer.waitFor();
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(1);
await page.getByRole("button", { name: "Remove attachment" }).click();
await expect.poll(async () => (await proof()).revoked).toBe(3);
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await navigate("chat");
await page.waitForURL((url) => url.pathname.endsWith("/chat"));
await expect.poll(async () => (await proof()).revoked).toBe(3);
await navigate("new-session");
await composer.waitFor();
await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(1);
await page.getByRole("button", { name: "Remove attachment" }).click();
await expect.poll(async () => (await proof()).revoked).toBe(4);
await pastePng(composer);
await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor();
await page.getByRole("button", { name: "Start session" }).click();
await page.waitForURL(
(url) => url.pathname === controlUiSessionPath("agent:main:preview-cleanup"),
);
await expect.poll(async () => await proof()).toEqual({ created: 5, revoked: 5 });
});
});
it("locks the submitted draft until creation settles and restores it after failure", async () => {
await withNewSessionPage(async (page) => {
const sessionKey = "agent:main:locked-new-session-draft";