mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user