mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(bsk): fix different type of upload
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import { type DownloadsApi, handleDownload } from "../download";
|
||||
import { uploadThroughFileChooser } from "../file-chooser";
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { handleUpload } from "../upload";
|
||||
|
||||
@@ -37,7 +38,7 @@ function fakeEvent<T extends (...args: never[]) => void>() {
|
||||
}
|
||||
|
||||
describe("file transfer tools", () => {
|
||||
it("arms chooser interception before clicking and injects only staged paths", async () => {
|
||||
it("enables chooser observation before clicking and injects only staged paths", async () => {
|
||||
const manager = sessions();
|
||||
const ctx = await manager.start("s1");
|
||||
ctx.refStore.set("e3", 123, { tabId: 4 });
|
||||
@@ -82,13 +83,135 @@ describe("file transfer tools", () => {
|
||||
|
||||
expect(result).toMatchObject({ tab_id: 4, file_names: ["one.png", "two.png"] });
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: "Page.setInterceptFileChooserDialog",
|
||||
params: { enabled: true },
|
||||
method: "Page.enable",
|
||||
params: { enableFileChooserOpenedEvent: true },
|
||||
});
|
||||
expect(calls).toContainEqual({
|
||||
method: "DOM.setFileInputFiles",
|
||||
params: { files: ["/private/stage/one", "/private/stage/two"], backendNodeId: 456 },
|
||||
});
|
||||
expect(calls.at(-1)).toEqual({
|
||||
method: "Page.enable",
|
||||
params: { enableFileChooserOpenedEvent: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports File System Access pickers as an explicit manual fallback", async () => {
|
||||
let eventHandler: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
|
||||
const cdp: CdpRunner = {
|
||||
onEvent: (handler) => {
|
||||
eventHandler = handler;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
send: vi.fn(async () => ({})) as unknown as CdpRunner["send"],
|
||||
};
|
||||
|
||||
const result = await uploadThroughFileChooser({
|
||||
cdp,
|
||||
target: { tabId: 4 },
|
||||
files: ["/private/stage/one"],
|
||||
timeoutMs: 100,
|
||||
trigger: async () => {
|
||||
eventHandler?.({ tabId: 4 }, "Page.fileChooserOpened", { mode: "selectSingle" });
|
||||
return { tab_id: 4, x: 10, y: 10 };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
code: "unsupported",
|
||||
message: "this file chooser is not backed by an input element",
|
||||
data: {
|
||||
reason: "unsupported_file_chooser",
|
||||
chooser_kind: "non_input",
|
||||
manual_fallback_available: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds a stuck chooser-control command and resets uncertain CDP state", async () => {
|
||||
const detach = vi.fn(async () => {});
|
||||
const cdp: CdpRunner = {
|
||||
onEvent: () => ({ dispose: vi.fn() }),
|
||||
send: vi.fn(() => new Promise<never>(() => {})) as unknown as CdpRunner["send"],
|
||||
detach,
|
||||
};
|
||||
|
||||
const result = await uploadThroughFileChooser({
|
||||
cdp,
|
||||
target: { tabId: 4 },
|
||||
files: ["/private/stage/one"],
|
||||
timeoutMs: 5,
|
||||
trigger: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: "timeout",
|
||||
data: { reason: "file_chooser_control_failed", phase: "enable_events" },
|
||||
});
|
||||
expect(detach).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it("distinguishes a completed click that did not open a chooser", async () => {
|
||||
const send = vi.fn(async () => ({})) as unknown as CdpRunner["send"];
|
||||
const cdp: CdpRunner = {
|
||||
onEvent: () => ({ dispose: vi.fn() }),
|
||||
send,
|
||||
};
|
||||
|
||||
const result = await uploadThroughFileChooser({
|
||||
cdp,
|
||||
target: { tabId: 4 },
|
||||
files: ["/private/stage/one"],
|
||||
timeoutMs: 5,
|
||||
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
code: "timeout",
|
||||
data: { reason: "file_chooser_not_opened", phase: "await_event" },
|
||||
});
|
||||
expect(send).toHaveBeenLastCalledWith(4, "Page.enable", {
|
||||
enableFileChooserOpenedEvent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts chooser events only from the exact CDP target", async () => {
|
||||
let eventHandler: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
|
||||
const setFiles = vi.fn();
|
||||
const cdp: CdpRunner = {
|
||||
onEvent: (handler) => {
|
||||
eventHandler = handler;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
send: vi.fn(async () => ({})) as unknown as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method, params) => {
|
||||
if (method === "DOM.setFileInputFiles") setFiles(params);
|
||||
return {};
|
||||
}) as unknown as NonNullable<CdpRunner["sendToTarget"]>,
|
||||
};
|
||||
|
||||
const result = await uploadThroughFileChooser({
|
||||
cdp,
|
||||
target: { tabId: 4, sessionId: "child-a" },
|
||||
files: ["/private/stage/one"],
|
||||
timeoutMs: 100,
|
||||
trigger: async () => {
|
||||
eventHandler?.({ tabId: 4, sessionId: "child-b" }, "Page.fileChooserOpened", {
|
||||
backendNodeId: 111,
|
||||
});
|
||||
eventHandler?.({ tabId: 4, sessionId: "child-a" }, "Page.fileChooserOpened", {
|
||||
backendNodeId: 222,
|
||||
});
|
||||
return { tab_id: 4, x: 10, y: 10 };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ click: { tab_id: 4 } });
|
||||
expect(setFiles).toHaveBeenCalledOnce();
|
||||
expect(setFiles).toHaveBeenCalledWith({
|
||||
files: ["/private/stage/one"],
|
||||
backendNodeId: 222,
|
||||
});
|
||||
});
|
||||
|
||||
it("captures the one download created under daemon staging", async () => {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// One file-chooser transaction for an upload. Chrome's extension debugger
|
||||
// bridge may leave a command pending indefinitely, so every state-changing
|
||||
// command is bounded and an uncertain target is detached before returning.
|
||||
|
||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import type { ClickResult, RpcError } from "@/transport/types";
|
||||
import { type CdpRunner, isRpcError, sendToCdpTarget } from "./shared";
|
||||
|
||||
const CLEANUP_TIMEOUT_MS = 1_000;
|
||||
|
||||
type ChooserPhase = "enable_events" | "trigger" | "await_event" | "set_files";
|
||||
|
||||
interface FileChooserOpened {
|
||||
source: { tabId?: number; sessionId?: string };
|
||||
backendNodeId?: number;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface FileChooserUploadOptions {
|
||||
cdp: CdpRunner;
|
||||
target: CdpTarget;
|
||||
files: string[];
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
trigger(timeoutMs: number): Promise<ClickResult | RpcError>;
|
||||
}
|
||||
|
||||
export interface FileChooserUploadResult {
|
||||
click: ClickResult;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
class BoundedWaitError extends Error {
|
||||
constructor(
|
||||
readonly kind: "timeout" | "aborted",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function remainingMs(deadline: number): number {
|
||||
return Math.max(0, deadline - Date.now());
|
||||
}
|
||||
|
||||
async function waitBounded<T>(
|
||||
promise: Promise<T>,
|
||||
deadline: number,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMessage: string,
|
||||
): Promise<T> {
|
||||
const remaining = remainingMs(deadline);
|
||||
if (signal?.aborted) throw new BoundedWaitError("aborted", "file chooser operation aborted");
|
||||
if (remaining === 0) throw new BoundedWaitError("timeout", timeoutMessage);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const boundary = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new BoundedWaitError("timeout", timeoutMessage)), remaining);
|
||||
if (signal) {
|
||||
onAbort = () => reject(new BoundedWaitError("aborted", "file chooser operation aborted"));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, boundary]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function chooserError(
|
||||
code: RpcError["code"],
|
||||
message: string,
|
||||
reason: "file_chooser_control_failed" | "file_chooser_not_opened",
|
||||
phase: ChooserPhase,
|
||||
): RpcError {
|
||||
return { code, message, data: { reason, phase } };
|
||||
}
|
||||
|
||||
function sameTarget(source: FileChooserOpened["source"], target: CdpTarget): boolean {
|
||||
return source.tabId === target.tabId && source.sessionId === target.sessionId;
|
||||
}
|
||||
|
||||
async function resetTarget(cdp: CdpRunner, tabId: number): Promise<void> {
|
||||
if (!cdp.detach) return;
|
||||
try {
|
||||
await waitBounded(
|
||||
cdp.detach(tabId),
|
||||
Date.now() + CLEANUP_TIMEOUT_MS,
|
||||
undefined,
|
||||
"CDP detach timed out",
|
||||
);
|
||||
} catch {
|
||||
// The target may already have detached or closed.
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadThroughFileChooser(
|
||||
options: FileChooserUploadOptions,
|
||||
): Promise<FileChooserUploadResult | RpcError> {
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
let resolveOpened!: (value: FileChooserOpened) => void;
|
||||
const opened = new Promise<FileChooserOpened>((resolve) => {
|
||||
resolveOpened = resolve;
|
||||
});
|
||||
const subscription = options.cdp.onEvent?.((source, method, raw) => {
|
||||
if (method !== "Page.fileChooserOpened" || !sameTarget(source, options.target)) return;
|
||||
const event = raw as { backendNodeId?: unknown; mode?: unknown };
|
||||
resolveOpened({
|
||||
source,
|
||||
...(typeof event.backendNodeId === "number" ? { backendNodeId: event.backendNodeId } : {}),
|
||||
...(typeof event.mode === "string" ? { mode: event.mode } : {}),
|
||||
});
|
||||
});
|
||||
if (!subscription) {
|
||||
return { code: "unsupported", message: "CDP event subscription unavailable" };
|
||||
}
|
||||
|
||||
let eventsEnabled = false;
|
||||
let targetStateUnknown = false;
|
||||
let outcome: FileChooserUploadResult | RpcError;
|
||||
let aborted: BoundedWaitError | undefined;
|
||||
try {
|
||||
try {
|
||||
await waitBounded(
|
||||
sendToCdpTarget(options.cdp, options.target, "Page.enable", {
|
||||
enableFileChooserOpenedEvent: true,
|
||||
}),
|
||||
deadline,
|
||||
options.signal,
|
||||
"enabling file chooser events timed out",
|
||||
);
|
||||
eventsEnabled = true;
|
||||
} catch (err) {
|
||||
if (err instanceof BoundedWaitError) {
|
||||
targetStateUnknown = true;
|
||||
if (err.kind === "aborted") throw err;
|
||||
outcome = chooserError(
|
||||
"timeout",
|
||||
err.message,
|
||||
"file_chooser_control_failed",
|
||||
"enable_events",
|
||||
);
|
||||
} else {
|
||||
outcome = chooserError(
|
||||
"cdp_failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"file_chooser_control_failed",
|
||||
"enable_events",
|
||||
);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let click: ClickResult | RpcError;
|
||||
try {
|
||||
click = await waitBounded(
|
||||
options.trigger(remainingMs(deadline)),
|
||||
deadline,
|
||||
options.signal,
|
||||
"upload trigger timed out",
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof BoundedWaitError) {
|
||||
targetStateUnknown = true;
|
||||
if (err.kind === "aborted") throw err;
|
||||
return chooserError("timeout", err.message, "file_chooser_control_failed", "trigger");
|
||||
}
|
||||
return chooserError(
|
||||
"cdp_failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"file_chooser_control_failed",
|
||||
"trigger",
|
||||
);
|
||||
}
|
||||
if (isRpcError(click)) return click;
|
||||
|
||||
let chooser: FileChooserOpened;
|
||||
try {
|
||||
chooser = await waitBounded(
|
||||
opened,
|
||||
deadline,
|
||||
options.signal,
|
||||
"upload trigger did not open a file chooser",
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof BoundedWaitError && err.kind === "aborted") throw err;
|
||||
return chooserError(
|
||||
"timeout",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"file_chooser_not_opened",
|
||||
"await_event",
|
||||
);
|
||||
}
|
||||
|
||||
if (chooser.backendNodeId === undefined) {
|
||||
return {
|
||||
code: "unsupported",
|
||||
message: "this file chooser is not backed by an input element",
|
||||
data: {
|
||||
reason: "unsupported_file_chooser",
|
||||
chooser_kind: "non_input",
|
||||
manual_fallback_available: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (chooser.mode === "selectSingle" && options.files.length !== 1) {
|
||||
return { code: "invalid_params", message: "file chooser accepts exactly one file" };
|
||||
}
|
||||
|
||||
try {
|
||||
await waitBounded(
|
||||
sendToCdpTarget(options.cdp, options.target, "DOM.setFileInputFiles", {
|
||||
files: options.files,
|
||||
backendNodeId: chooser.backendNodeId,
|
||||
}),
|
||||
deadline,
|
||||
options.signal,
|
||||
"setting file input files timed out",
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof BoundedWaitError) {
|
||||
targetStateUnknown = true;
|
||||
if (err.kind === "aborted") throw err;
|
||||
return chooserError("timeout", err.message, "file_chooser_control_failed", "set_files");
|
||||
}
|
||||
return chooserError(
|
||||
"cdp_failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
"file_chooser_control_failed",
|
||||
"set_files",
|
||||
);
|
||||
}
|
||||
outcome = { click, ...(chooser.mode ? { mode: chooser.mode } : {}) };
|
||||
} catch (err) {
|
||||
if (err instanceof BoundedWaitError && err.kind === "aborted") aborted = err;
|
||||
else throw err;
|
||||
outcome = { code: "cancelled", message: "file chooser operation aborted" };
|
||||
} finally {
|
||||
subscription.dispose();
|
||||
if (targetStateUnknown) {
|
||||
await resetTarget(options.cdp, options.target.tabId);
|
||||
} else if (eventsEnabled) {
|
||||
try {
|
||||
await waitBounded(
|
||||
sendToCdpTarget(options.cdp, options.target, "Page.enable", {
|
||||
enableFileChooserOpenedEvent: false,
|
||||
}),
|
||||
Date.now() + CLEANUP_TIMEOUT_MS,
|
||||
undefined,
|
||||
"restoring file chooser events timed out",
|
||||
);
|
||||
} catch {
|
||||
await resetTarget(options.cdp, options.target.tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (aborted) {
|
||||
const error = new DOMException(aborted.message, "AbortError");
|
||||
throw error;
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export type { DialogCursor };
|
||||
export interface CdpRunner {
|
||||
send<T = unknown>(tabId: number, method: string, params?: object): Promise<T>;
|
||||
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
||||
detach?(tabId: number): Promise<void>;
|
||||
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
||||
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Atomic file-chooser upload: arm interception before clicking, then attach
|
||||
// daemon-staged files to the chooser node. No local path is accepted from an
|
||||
// agent-facing call; staged_path is injected by the daemon.
|
||||
// Upload orchestration: validate the session-scoped request, resolve its click
|
||||
// target, then delegate the browser protocol transaction to file-chooser.ts.
|
||||
|
||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type { ClickParams, RpcError, UploadParams, UploadResult } from "@/transport/types";
|
||||
import { uploadThroughFileChooser } from "./file-chooser";
|
||||
import { handleClick, type InteractionDeps } from "./interaction";
|
||||
import {
|
||||
type CdpRunner,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
isRpcError,
|
||||
lookupSession,
|
||||
resolveTargetTab,
|
||||
sendToCdpTarget,
|
||||
} from "./shared";
|
||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||
|
||||
@@ -60,89 +59,28 @@ export async function handleUpload(
|
||||
const cdpTarget = chooserTarget(params, target.tabId, manager);
|
||||
if (isRpcError(cdpTarget)) return cdpTarget;
|
||||
const timeoutMs = params.timeout_ms ?? DEFAULT_TIMEOUT_MS;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let subscription: { dispose(): void } | undefined;
|
||||
let resolveChooser!: (value: {
|
||||
source: { tabId?: number; sessionId?: string };
|
||||
backendNodeId: number;
|
||||
mode?: string;
|
||||
}) => void;
|
||||
let rejectChooser!: (error: Error) => void;
|
||||
const chooser = new Promise<{
|
||||
source: { tabId?: number; sessionId?: string };
|
||||
backendNodeId: number;
|
||||
mode?: string;
|
||||
}>((resolve, reject) => {
|
||||
resolveChooser = resolve;
|
||||
rejectChooser = reject;
|
||||
});
|
||||
subscription = deps.cdp.onEvent((source, method, raw) => {
|
||||
if (method !== "Page.fileChooserOpened" || source.tabId !== target.tabId) return;
|
||||
if (cdpTarget.sessionId && source.sessionId !== cdpTarget.sessionId) return;
|
||||
const event = raw as { backendNodeId?: unknown; mode?: unknown };
|
||||
if (typeof event.backendNodeId !== "number") {
|
||||
rejectChooser(new Error("file chooser did not expose an input backendNodeId"));
|
||||
return;
|
||||
}
|
||||
resolveChooser({
|
||||
source,
|
||||
backendNodeId: event.backendNodeId,
|
||||
...(typeof event.mode === "string" ? { mode: event.mode } : {}),
|
||||
});
|
||||
});
|
||||
timer = setTimeout(
|
||||
() => rejectChooser(new Error("file chooser did not open before timeout")),
|
||||
const transaction = await uploadThroughFileChooser({
|
||||
cdp: deps.cdp,
|
||||
target: cdpTarget,
|
||||
files: params.files.map((file) => file.staged_path as string),
|
||||
timeoutMs,
|
||||
);
|
||||
const onAbort = () => rejectChooser(new DOMException("aborted", "AbortError"));
|
||||
deps.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await sendToCdpTarget(deps.cdp, cdpTarget, "Page.setInterceptFileChooserDialog", {
|
||||
enabled: true,
|
||||
});
|
||||
const clickParams: ClickParams = {
|
||||
session_id: params.session_id,
|
||||
ref: params.ref,
|
||||
selector: params.selector,
|
||||
tab_id: params.tab_id,
|
||||
timeout_ms: params.timeout_ms,
|
||||
};
|
||||
const clicked = await handleClick(manager, clickParams, deps);
|
||||
if (isRpcError(clicked)) {
|
||||
void chooser.catch(() => undefined);
|
||||
return clicked;
|
||||
}
|
||||
const opened = await chooser;
|
||||
if (opened.mode === "selectSingle" && params.files.length !== 1) {
|
||||
return { code: "invalid_params", message: "file chooser accepts exactly one file" };
|
||||
}
|
||||
const sourceTarget: CdpTarget = {
|
||||
tabId: target.tabId,
|
||||
...(opened.source.sessionId ? { sessionId: opened.source.sessionId } : {}),
|
||||
};
|
||||
await sendToCdpTarget(deps.cdp, sourceTarget, "DOM.setFileInputFiles", {
|
||||
files: params.files.map((file) => file.staged_path as string),
|
||||
backendNodeId: opened.backendNodeId,
|
||||
});
|
||||
return {
|
||||
tab_id: target.tabId,
|
||||
used_ref: clicked.used_ref,
|
||||
used_selector: clicked.used_selector,
|
||||
file_names: params.files.map((file) => file.name),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
subscription?.dispose();
|
||||
deps.signal?.removeEventListener("abort", onAbort);
|
||||
try {
|
||||
await sendToCdpTarget(deps.cdp, cdpTarget, "Page.setInterceptFileChooserDialog", {
|
||||
enabled: false,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort compensation; the target may have navigated/closed.
|
||||
}
|
||||
}
|
||||
signal: deps.signal,
|
||||
trigger: (remaining) => {
|
||||
const clickParams: ClickParams = {
|
||||
session_id: params.session_id,
|
||||
ref: params.ref,
|
||||
selector: params.selector,
|
||||
tab_id: params.tab_id,
|
||||
timeout_ms: Math.max(1, remaining),
|
||||
};
|
||||
return handleClick(manager, clickParams, deps);
|
||||
},
|
||||
});
|
||||
if (isRpcError(transaction)) return transaction;
|
||||
return {
|
||||
tab_id: target.tabId,
|
||||
used_ref: transaction.click.used_ref,
|
||||
used_selector: transaction.click.used_selector,
|
||||
file_names: params.files.map((file) => file.name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export type RpcErrorReason =
|
||||
| "restricted_tab_url"
|
||||
| "borrow_conflict"
|
||||
| "screenshot_capture_failed"
|
||||
| "file_chooser_control_failed"
|
||||
| "file_chooser_not_opened"
|
||||
| "unsupported_file_chooser"
|
||||
| "cleanup_failed";
|
||||
|
||||
export interface RpcErrorData {
|
||||
|
||||
Reference in New Issue
Block a user