Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 75 additions & 14 deletions extension/dist/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,22 +183,83 @@ async function screenshot(tabId, options = {}) {
}
}
}
const FILE_CHOOSER_TIMEOUT_MS = 1e4;
const pendingFileChooserTabs = /* @__PURE__ */ new Set();
const FILE_UPLOAD_OBJECT_GROUP = "opencli-file-upload";
async function setFileInputFiles(tabId, files, selector) {
await ensureAttached(tabId);
await sendDebuggerCommand({ tabId }, "DOM.enable");
const doc = await sendDebuggerCommand({ tabId }, "DOM.getDocument");
const query = selector || 'input[type="file"]';
const result = await sendDebuggerCommand({ tabId }, "DOM.querySelector", {
nodeId: doc.root.nodeId,
selector: query
});
if (!result.nodeId) {
throw new Error(`No element found matching selector: ${query}`);
if (pendingFileChooserTabs.has(tabId)) {
throw new Error(`Another file upload is already in progress on tab ${tabId}.`);
}
pendingFileChooserTabs.add(tabId);
try {
await ensureAttached(tabId);
await sendDebuggerCommand({ tabId }, "DOM.enable");
await sendDebuggerCommand({ tabId }, "Page.enable");
const doc = await sendDebuggerCommand({ tabId }, "DOM.getDocument");
const query = selector || 'input[type="file"]';
const result = await sendDebuggerCommand({ tabId }, "DOM.querySelector", {
nodeId: doc.root.nodeId,
selector: query
});
if (!result.nodeId) {
throw new Error(`No element found matching selector: ${query}`);
}
const resolved = await sendDebuggerCommand({ tabId }, "DOM.resolveNode", {
nodeId: result.nodeId,
objectGroup: FILE_UPLOAD_OBJECT_GROUP
});
const objectId = resolved?.object?.objectId;
if (!objectId) {
throw new Error(`Could not resolve file input element for selector: ${query}`);
}
let onChooser;
const chooserOpened = new Promise((resolve) => {
onChooser = (source, method, params) => {
if (source.tabId !== tabId || method !== "Page.fileChooserOpened") return;
resolve({ backendNodeId: params?.backendNodeId, mode: params?.mode });
};
chrome.debugger.onEvent.addListener(onChooser);
});
try {
await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {
});
await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: true });
await sendDebuggerCommand({ tabId }, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: "function() { this.click(); }",
userGesture: true
});
let timer;
const chooser = await Promise.race([
chooserOpened,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(
`Timed out waiting for Page.fileChooserOpened after clicking the file input (${Math.round(FILE_CHOOSER_TIMEOUT_MS / 1e3)}s). The input may be disabled or the page may have prevented the click.`
)), FILE_CHOOSER_TIMEOUT_MS);
})
]).finally(() => {
if (timer !== void 0) clearTimeout(timer);
});
if (chooser.mode === "selectSingle" && files.length > 1) {
throw new Error(`File input only accepts a single file, got ${files.length}.`);
}
if (typeof chooser.backendNodeId !== "number") {
throw new Error("File chooser opened without a backing file input (no backendNodeId in Page.fileChooserOpened).");
}
await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", {
files,
backendNodeId: chooser.backendNodeId
});
} finally {
if (onChooser) chrome.debugger.onEvent.removeListener(onChooser);
await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {
});
await sendDebuggerCommand({ tabId }, "Runtime.releaseObjectGroup", { objectGroup: FILE_UPLOAD_OBJECT_GROUP }).catch(() => {
});
}
} finally {
pendingFileChooserTabs.delete(tabId);
}
await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", {
files,
nodeId: result.nodeId
});
}
function matchesDownloadPattern(item, pattern) {
if (!pattern) return true;
Expand Down
2 changes: 1 addition & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "OpenCLI",
"version": "1.0.22",
"version": "1.0.23",
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.",
"permissions": [
"debugger",
Expand Down
2 changes: 1 addition & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencli-extension",
"version": "1.0.22",
"version": "1.0.23",
"private": true,
"opencli": {
"compatRange": ">=1.7.0"
Expand Down
133 changes: 133 additions & 0 deletions extension/src/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,3 +654,136 @@ describe('cdp command deadline', () => {
await assertion;
});
});

describe('cdp setFileInputFiles (file-chooser interception)', () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});

// chrome.debugger clients may not call DOM.setFileInputFiles directly
// (Chromium #928255) — the flow must intercept the file chooser and answer
// the Page.fileChooserOpened event's backendNodeId.
function chromeMockForFileUpload(chooserEvent: { backendNodeId?: number; mode?: string } | null = { backendNodeId: 42, mode: 'selectMultiple' }) {
const calls: Array<{ method: string; params?: any }> = [];
const eventListeners: Array<(source: { tabId?: number }, method: string, params: any) => void> = [];
const debuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async (_target: unknown, method: string, params?: any) => {
calls.push({ method, params });
if (method === 'DOM.getDocument') return { root: { nodeId: 1 } };
if (method === 'DOM.querySelector') return { nodeId: 7 };
if (method === 'DOM.resolveNode') return { object: { objectId: 'obj-7' } };
if (method === 'Runtime.callFunctionOn' && chooserEvent) {
queueMicrotask(() => {
for (const fn of [...eventListeners]) fn({ tabId: 1 }, 'Page.fileChooserOpened', chooserEvent);
});
}
return {};
}),
onDetach: { addListener: vi.fn() },
onEvent: {
addListener: vi.fn((fn: (source: { tabId?: number }, method: string, params: any) => void) => { eventListeners.push(fn); }),
removeListener: vi.fn((fn: (source: { tabId?: number }, method: string, params: any) => void) => {
const i = eventListeners.indexOf(fn);
if (i >= 0) eventListeners.splice(i, 1);
}),
},
};
const tabs = {
get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://example.com' })),
onRemoved: { addListener: vi.fn() },
onUpdated: { addListener: vi.fn() },
};
return {
chrome: { tabs, debugger: debuggerApi, scripting: {}, runtime: { id: 'opencli-test' } },
debuggerApi,
calls,
};
}

it('answers the intercepted chooser with the event backendNodeId instead of calling DOM.setFileInputFiles directly', async () => {
const { chrome, calls, debuggerApi } = chromeMockForFileUpload();
vi.stubGlobal('chrome', chrome);

const mod = await import('./cdp');
await mod.setFileInputFiles(1, ['/tmp/a.txt'], 'input[type=file]');

const methods = calls.map((c) => c.method);
const interceptOn = calls.findIndex((c) => c.method === 'Page.setInterceptFileChooserDialog' && c.params?.enabled === true);
const click = calls.findIndex((c) => c.method === 'Runtime.callFunctionOn');
const setFiles = calls.findIndex((c) => c.method === 'DOM.setFileInputFiles');
expect(interceptOn).toBeGreaterThan(-1);
expect(click).toBeGreaterThan(interceptOn);
expect(setFiles).toBeGreaterThan(click);
expect(calls[click].params).toMatchObject({ objectId: 'obj-7', userGesture: true });
expect(calls[setFiles].params).toEqual({ files: ['/tmp/a.txt'], backendNodeId: 42 });
expect(methods).toContain('Page.enable');
// Interception is switched back off after the upload.
expect(calls.filter((c) => c.method === 'Page.setInterceptFileChooserDialog').at(-1)?.params).toEqual({ enabled: false });
expect(debuggerApi.onEvent.removeListener).toHaveBeenCalled();
});

it('rejects multiple files when the chooser reports selectSingle', async () => {
const { chrome, calls } = chromeMockForFileUpload({ backendNodeId: 42, mode: 'selectSingle' });
vi.stubGlobal('chrome', chrome);

const mod = await import('./cdp');
await expect(mod.setFileInputFiles(1, ['/tmp/a.txt', '/tmp/b.txt']))
.rejects.toThrow(/single file/);
expect(calls.some((c) => c.method === 'DOM.setFileInputFiles')).toBe(false);
expect(calls.filter((c) => c.method === 'Page.setInterceptFileChooserDialog').at(-1)?.params).toEqual({ enabled: false });
});

it('rejects when the chooser event carries no backendNodeId', async () => {
const { chrome, calls, debuggerApi } = chromeMockForFileUpload({ mode: 'selectSingle' });
vi.stubGlobal('chrome', chrome);

const mod = await import('./cdp');
await expect(mod.setFileInputFiles(1, ['/tmp/a.txt']))
.rejects.toThrow(/no backendNodeId/);
expect(calls.some((c) => c.method === 'DOM.setFileInputFiles')).toBe(false);
expect(debuggerApi.onEvent.removeListener).toHaveBeenCalled();
});

it('times out with a clear error when Page.fileChooserOpened never arrives, and still disables interception', async () => {
vi.useFakeTimers();
const { chrome, calls, debuggerApi } = chromeMockForFileUpload(null);
vi.stubGlobal('chrome', chrome);

const mod = await import('./cdp');
const pending = mod.setFileInputFiles(1, ['/tmp/a.txt']);
const assertion = expect(pending).rejects.toThrow(/Timed out waiting for Page.fileChooserOpened/);
await vi.advanceTimersByTimeAsync(10_000);
await assertion;

expect(calls.some((c) => c.method === 'DOM.setFileInputFiles')).toBe(false);
expect(calls.filter((c) => c.method === 'Page.setInterceptFileChooserDialog').at(-1)?.params).toEqual({ enabled: false });
expect(debuggerApi.onEvent.removeListener).toHaveBeenCalled();
});

it('rejects an overlapping upload on the same tab while one is in flight', async () => {
vi.useFakeTimers();
const { chrome } = chromeMockForFileUpload(null);
vi.stubGlobal('chrome', chrome);

const mod = await import('./cdp');
const first = mod.setFileInputFiles(1, ['/tmp/a.txt']);
const firstAssertion = expect(first).rejects.toThrow(/Timed out/);
await expect(mod.setFileInputFiles(1, ['/tmp/b.txt']))
.rejects.toThrow(/already in progress/);
await vi.advanceTimersByTimeAsync(10_000);
await firstAssertion;

// The guard is released after the first call settles.
const third = mod.setFileInputFiles(1, ['/tmp/c.txt']);
const thirdAssertion = expect(third).rejects.toThrow(/Timed out/);
await vi.advanceTimersByTimeAsync(10_000);
await thirdAssertion;
});
});
Loading