mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(ui): copy task results through clipboard fallback (#127181)
Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-b547-7788-a916-d4a94cbd3e3b Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
committed by
GitHub
parent
ff83e3efe8
commit
2cbfade48b
@@ -46,6 +46,27 @@ describe("copyToClipboard", () => {
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips fallback when the caller retires a rejected async write", async () => {
|
||||
let rejectWrite: ((reason?: unknown) => void) | undefined;
|
||||
const writeText = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectWrite = reject;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
const exec = mockExecCommand(true);
|
||||
let current = true;
|
||||
|
||||
const copy = copyToClipboard("hello", () => current);
|
||||
current = false;
|
||||
rejectWrite?.(new Error("denied"));
|
||||
|
||||
expect(await copy).toBe(false);
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("textarea")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to execCommand over plain HTTP where navigator.clipboard is undefined", async () => {
|
||||
vi.stubGlobal("navigator", {});
|
||||
const exec = mockExecCommand(true);
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
// is undefined, so calling it throws synchronously rather than rejecting. Guard
|
||||
// the secure-context path and fall back to the legacy execCommand copy so the
|
||||
// copy buttons keep working over HTTP. Returns whether the copy succeeded.
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
export async function copyToClipboard(
|
||||
text: string,
|
||||
shouldFallback?: () => boolean,
|
||||
): Promise<boolean> {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
@@ -18,6 +21,11 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// fall through to the execCommand path before giving up.
|
||||
}
|
||||
}
|
||||
// A rejected async write can settle after newer caller-owned work. Let that
|
||||
// owner retire this second transport attempt before it mutates the clipboard.
|
||||
if (shouldFallback && !shouldFallback()) {
|
||||
return false;
|
||||
}
|
||||
return copyWithExecCommand(text);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ type TasksPageTestElement = HTMLElement & {
|
||||
context: ApplicationContext;
|
||||
tasks: TaskSummary[];
|
||||
error: string | null;
|
||||
copyResultError: string | null;
|
||||
cancellingTaskIds: Set<string>;
|
||||
cancelTask: (taskId: string) => Promise<void>;
|
||||
copyTaskResult: (taskId: string) => Promise<void>;
|
||||
recoverTask: (taskId: string, action: "retry" | "dismiss") => Promise<void>;
|
||||
refreshTasks: () => Promise<void>;
|
||||
};
|
||||
@@ -162,6 +164,7 @@ function createContext(
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("TasksPage concurrent refresh events", () => {
|
||||
@@ -447,6 +450,49 @@ describe("TasksPage active pagination", () => {
|
||||
});
|
||||
|
||||
describe("TasksPage cancellation lifecycle", () => {
|
||||
it("does not clear an unrelated task error when a result copy succeeds", async () => {
|
||||
const blocked = createTask("task-copy-independent-error", "completed", {
|
||||
deliveryStatus: "failed",
|
||||
terminalOutcome: "blocked",
|
||||
});
|
||||
const clipboardWrite = deferred<void>();
|
||||
const writeText = vi.fn(() => clipboardWrite.promise);
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "tasks.get") {
|
||||
return Promise.resolve({ task: { ...blocked, result: "Retained result" } });
|
||||
}
|
||||
if (method === "tasks.retry") {
|
||||
return Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
taskId: blocked.taskId,
|
||||
ok: false,
|
||||
reason: "Independent recovery failed",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ tasks: [blocked] });
|
||||
});
|
||||
const source = createGateway({ request } as unknown as GatewayBrowserClient);
|
||||
const page = document.createElement("openclaw-tasks-page") as TasksPageTestElement;
|
||||
page.context = createContext(source.gateway);
|
||||
document.body.append(page);
|
||||
await waitForFast(() => expect(page.tasks).toHaveLength(1));
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
|
||||
const copying = page.copyTaskResult(blocked.taskId);
|
||||
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith("Retained result"));
|
||||
await page.recoverTask(blocked.taskId, "retry");
|
||||
expect(page.error).toBe("Independent recovery failed");
|
||||
|
||||
clipboardWrite.resolve(undefined);
|
||||
await copying;
|
||||
|
||||
expect(page.error).toBe("Independent recovery failed");
|
||||
expect(page.copyResultError).toBeNull();
|
||||
});
|
||||
|
||||
it("lets a read-only operator copy a retained result without mutation controls", async () => {
|
||||
const retained = createTask("task-read-only-retained", "completed", {
|
||||
deliveryStatus: "failed",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { hasOperatorReadAccess, hasOperatorWriteAccess } from "../../app/operato
|
||||
import { renderAgentScopeControl } from "../../components/agent-scope-control.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { watchAgentScope } from "../../lib/agents/index.ts";
|
||||
import { copyToClipboard } from "../../lib/clipboard.ts";
|
||||
import { formatUiError, formatUiExternalText } from "../../lib/format-error.ts";
|
||||
import {
|
||||
findUiSessionRow,
|
||||
@@ -99,14 +100,17 @@ class TasksPage extends OpenClawLightDomElement {
|
||||
|
||||
@state() private tasks: TaskSummary[] = [];
|
||||
@state() private error: string | null = null;
|
||||
@state() private copyResultError: string | null = null;
|
||||
@state() private cancellingTaskIds = new Set<string>();
|
||||
|
||||
private taskRefreshEvents: TaskRefreshEventBuffer | null = null;
|
||||
private copyResultAttempt = 0;
|
||||
private readonly gateway = new GatewayPageController(this, {
|
||||
getGateway: () => this.context?.gateway,
|
||||
onIdentityChange: () => {
|
||||
this.tasks = [];
|
||||
this.error = null;
|
||||
this.copyResultError = null;
|
||||
},
|
||||
invalidateRequests: () => this.cancelGatewayWork(),
|
||||
onSnapshot: () => {
|
||||
@@ -238,6 +242,8 @@ class TasksPage extends OpenClawLightDomElement {
|
||||
);
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.copyResultAttempt += 1;
|
||||
this.copyResultError = null;
|
||||
this.subscriptions.clear();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -245,6 +251,8 @@ class TasksPage extends OpenClawLightDomElement {
|
||||
private cancelGatewayWork() {
|
||||
// Reconnects may reuse the client object; the epoch keeps pre-disconnect
|
||||
// cancellation responses from mutating the replacement task snapshot.
|
||||
this.copyResultAttempt += 1;
|
||||
this.copyResultError = null;
|
||||
this.taskRefreshEvents = null;
|
||||
void this.listTask.run([null, null, null]);
|
||||
this.cancellingTaskIds = new Set();
|
||||
@@ -258,6 +266,7 @@ class TasksPage extends OpenClawLightDomElement {
|
||||
}
|
||||
const scopeId = this.context.agentSelection.state.scopeId;
|
||||
this.error = null;
|
||||
this.copyResultError = null;
|
||||
return this.listTask.run([gateway, client, scopeId]);
|
||||
}
|
||||
|
||||
@@ -353,6 +362,7 @@ class TasksPage extends OpenClawLightDomElement {
|
||||
}
|
||||
|
||||
private async copyTaskResult(taskId: string) {
|
||||
const attempt = ++this.copyResultAttempt;
|
||||
const scope = this.gateway.capture();
|
||||
const gateway = this.gateway.gateway;
|
||||
if (!scope || !gateway || this.context.gateway !== gateway) {
|
||||
@@ -360,18 +370,24 @@ class TasksPage extends OpenClawLightDomElement {
|
||||
}
|
||||
try {
|
||||
const detail = normalizeTasksGetResult(await scope.client.request("tasks.get", { taskId }));
|
||||
if (!this.gateway.isCurrent(scope)) {
|
||||
if (!this.gateway.isCurrent(scope) || attempt !== this.copyResultAttempt) {
|
||||
return;
|
||||
}
|
||||
const result = detail?.result ?? detail?.progressSummary;
|
||||
if (!result) {
|
||||
this.error = t("tasksPage.recoveryFailed");
|
||||
this.copyResultError = t("tasksPage.recoveryFailed");
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(result);
|
||||
const copied = await copyToClipboard(
|
||||
result,
|
||||
() => this.gateway.isCurrent(scope) && attempt === this.copyResultAttempt,
|
||||
);
|
||||
if (this.gateway.isCurrent(scope) && attempt === this.copyResultAttempt) {
|
||||
this.copyResultError = copied ? null : t("common.copyFailed");
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.gateway.isCurrent(scope)) {
|
||||
this.error = formatUiError(error, t("tasksPage.recoveryFailed"));
|
||||
if (this.gateway.isCurrent(scope) && attempt === this.copyResultAttempt) {
|
||||
this.copyResultError = formatUiError(error, t("tasksPage.recoveryFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,6 +429,7 @@ class TasksPage extends OpenClawLightDomElement {
|
||||
canCancel: hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
|
||||
loading: this.listTask.status === TaskStatus.PENDING,
|
||||
error: this.error,
|
||||
copyResultError: this.copyResultError,
|
||||
tasks: this.tasks,
|
||||
cancellingTaskIds: this.cancellingTaskIds,
|
||||
sessionRow: (sessionKey) => findUiSessionRow(this.context, sessionKey),
|
||||
|
||||
@@ -81,6 +81,19 @@ const readOnlyRetainedTask = {
|
||||
};
|
||||
|
||||
const readOnlyRetainedResult = "Synthetic retained result copied by a read-only operator.";
|
||||
const olderRetainedResult = "Older retained result from the first copy activation.";
|
||||
const newestRetainedResult = "Newest retained result from the second copy activation.";
|
||||
|
||||
type ClipboardFaultState = {
|
||||
asyncWrites: string[];
|
||||
execSucceeds: boolean;
|
||||
legacyWrites: string[];
|
||||
mode: "defer" | "missing" | "reject";
|
||||
pending: Array<{
|
||||
reject: (reason?: unknown) => void;
|
||||
resolve: () => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
const retryBlockedTask = {
|
||||
...readOnlyRetainedTask,
|
||||
@@ -484,15 +497,50 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an operator.read-only user copy a retained result without mutations", async () => {
|
||||
it("copies retained results through fallback and announces total failure", async () => {
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
});
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"], {
|
||||
origin: new URL(suite.server.baseUrl).origin,
|
||||
await context.addInitScript(() => {
|
||||
const state: ClipboardFaultState = {
|
||||
asyncWrites: [],
|
||||
execSucceeds: true,
|
||||
legacyWrites: [],
|
||||
mode: "reject",
|
||||
pending: [],
|
||||
};
|
||||
Object.defineProperty(window, "tasksClipboardFault", { value: state });
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
get: () =>
|
||||
state.mode === "missing"
|
||||
? undefined
|
||||
: {
|
||||
writeText(text: string) {
|
||||
state.asyncWrites.push(text);
|
||||
if (state.mode === "defer") {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
state.pending.push({ reject, resolve });
|
||||
});
|
||||
}
|
||||
return Promise.reject(
|
||||
new DOMException("Clipboard access denied", "NotAllowedError"),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
document.execCommand = (command: string) => {
|
||||
if (command !== "copy") {
|
||||
return false;
|
||||
}
|
||||
state.legacyWrites.push(
|
||||
document.querySelector<HTMLTextAreaElement>("textarea")?.value ?? "",
|
||||
);
|
||||
return state.execSucceeds;
|
||||
};
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
@@ -512,7 +560,13 @@ suite.define(() => {
|
||||
],
|
||||
},
|
||||
"tasks.get": {
|
||||
task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult },
|
||||
sequence: [
|
||||
{ task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult } },
|
||||
{ task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult } },
|
||||
{ task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult } },
|
||||
{ task: { ...readOnlyRetainedTask, result: olderRetainedResult } },
|
||||
{ task: { ...readOnlyRetainedTask, result: newestRetainedResult } },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -533,11 +587,166 @@ suite.define(() => {
|
||||
const copyButton = task.getByRole("button", { name: "Copy result" });
|
||||
await copyButton.waitFor({ state: "visible" });
|
||||
await copyButton.click();
|
||||
const getRequest = await gateway.waitForRequest("tasks.get");
|
||||
expect(getRequest.params).toEqual({ taskId: readOnlyRetainedTask.taskId });
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(readOnlyRetainedResult);
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
|
||||
.tasksClipboardFault,
|
||||
),
|
||||
)
|
||||
.toMatchObject({
|
||||
asyncWrites: [readOnlyRetainedResult],
|
||||
legacyWrites: [readOnlyRetainedResult],
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as typeof window & { tasksClipboardFault: ClipboardFaultState }
|
||||
).tasksClipboardFault.mode = "missing";
|
||||
});
|
||||
await copyButton.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
|
||||
.tasksClipboardFault,
|
||||
),
|
||||
)
|
||||
.toMatchObject({
|
||||
asyncWrites: [readOnlyRetainedResult],
|
||||
legacyWrites: [readOnlyRetainedResult, readOnlyRetainedResult],
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const state = (window as typeof window & { tasksClipboardFault: ClipboardFaultState })
|
||||
.tasksClipboardFault;
|
||||
state.mode = "reject";
|
||||
state.execSucceeds = false;
|
||||
});
|
||||
await copyButton.click();
|
||||
await expect.poll(() => page.getByRole("alert").textContent()).toBe("Copy failed");
|
||||
await page.screenshot({ path: path.join(artifactDir, "05-copy-failed.png") });
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
|
||||
.tasksClipboardFault,
|
||||
),
|
||||
).toMatchObject({
|
||||
asyncWrites: [readOnlyRetainedResult, readOnlyRetainedResult],
|
||||
legacyWrites: [readOnlyRetainedResult, readOnlyRetainedResult, readOnlyRetainedResult],
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const state = (window as typeof window & { tasksClipboardFault: ClipboardFaultState })
|
||||
.tasksClipboardFault;
|
||||
state.mode = "defer";
|
||||
state.execSucceeds = false;
|
||||
});
|
||||
await copyButton.click();
|
||||
await copyButton.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
|
||||
.tasksClipboardFault.pending.length,
|
||||
),
|
||||
)
|
||||
.toBe(2);
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as typeof window & { tasksClipboardFault: ClipboardFaultState }
|
||||
).tasksClipboardFault.pending[1]?.reject(
|
||||
new DOMException("Clipboard access denied", "NotAllowedError"),
|
||||
);
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
|
||||
.tasksClipboardFault.legacyWrites.length,
|
||||
),
|
||||
)
|
||||
.toBe(4);
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as typeof window & { tasksClipboardFault: ClipboardFaultState }
|
||||
).tasksClipboardFault.pending[0]?.reject(
|
||||
new DOMException("Clipboard access denied", "NotAllowedError"),
|
||||
);
|
||||
});
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, 0);
|
||||
}),
|
||||
);
|
||||
const currentAlert = page.getByRole("alert");
|
||||
expect(await currentAlert.count()).toBe(1);
|
||||
expect(await currentAlert.textContent()).toBe("Copy failed");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
|
||||
.tasksClipboardFault.legacyWrites,
|
||||
),
|
||||
).toEqual([
|
||||
readOnlyRetainedResult,
|
||||
readOnlyRetainedResult,
|
||||
readOnlyRetainedResult,
|
||||
newestRetainedResult,
|
||||
]);
|
||||
|
||||
await gateway.deferNext("tasks.get", { taskId: readOnlyRetainedTask.taskId });
|
||||
await copyButton.click();
|
||||
await expect.poll(() => gateway.getRequests("tasks.get")).toHaveLength(6);
|
||||
expect(await page.getByRole("alert").textContent()).toBe("Copy failed");
|
||||
const socketCount = await gateway.getSocketCount();
|
||||
await gateway.closeLatest(1012, "retire retained-result copy");
|
||||
await expect.poll(() => gateway.getSocketCount()).toBeGreaterThan(socketCount);
|
||||
await gateway.resolveDeferred("tasks.get", {
|
||||
task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult },
|
||||
});
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, 0);
|
||||
}),
|
||||
);
|
||||
expect(await page.getByRole("alert").count()).toBe(0);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
|
||||
.tasksClipboardFault,
|
||||
),
|
||||
).toMatchObject({
|
||||
asyncWrites: [
|
||||
readOnlyRetainedResult,
|
||||
readOnlyRetainedResult,
|
||||
olderRetainedResult,
|
||||
newestRetainedResult,
|
||||
],
|
||||
legacyWrites: [
|
||||
readOnlyRetainedResult,
|
||||
readOnlyRetainedResult,
|
||||
readOnlyRetainedResult,
|
||||
newestRetainedResult,
|
||||
],
|
||||
});
|
||||
|
||||
expect((await gateway.getRequests("tasks.get")).map((request) => request.params)).toEqual([
|
||||
{ taskId: readOnlyRetainedTask.taskId },
|
||||
{ taskId: readOnlyRetainedTask.taskId },
|
||||
{ taskId: readOnlyRetainedTask.taskId },
|
||||
{ taskId: readOnlyRetainedTask.taskId },
|
||||
{ taskId: readOnlyRetainedTask.taskId },
|
||||
{ taskId: readOnlyRetainedTask.taskId },
|
||||
]);
|
||||
expect(await gateway.getRequests("tasks.retry")).toHaveLength(0);
|
||||
expect(await gateway.getRequests("tasks.dismiss")).toHaveLength(0);
|
||||
expect(await gateway.getRequests("tasks.cancel")).toHaveLength(0);
|
||||
|
||||
@@ -29,6 +29,7 @@ type TasksProps = {
|
||||
canCancel: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
copyResultError: string | null;
|
||||
tasks: TaskSummary[];
|
||||
cancellingTaskIds: ReadonlySet<string>;
|
||||
sessionRow: (sessionKey: string) => GatewaySessionRow | undefined;
|
||||
@@ -255,7 +256,10 @@ export function renderTasks(props: TasksProps) {
|
||||
${!props.connected
|
||||
? html`<div class="callout warn">${t("tasksPage.disconnected")}</div>`
|
||||
: nothing}
|
||||
${props.error ? html`<div class="callout danger">${props.error}</div>` : nothing}
|
||||
${props.error ? html`<div class="callout danger" role="alert">${props.error}</div>` : nothing}
|
||||
${props.copyResultError
|
||||
? html`<div class="callout danger" role="alert">${props.copyResultError}</div>`
|
||||
: nothing}
|
||||
${renderSummaryStrip(props.tasks)}
|
||||
${props.loading && props.tasks.length === 0
|
||||
? html`<div class="card muted">${t("tasksPage.loading")}</div>`
|
||||
|
||||
Reference in New Issue
Block a user