From eaa5e93b8b937667fa997762532521a42b177882 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 25 Nov 2019 13:56:39 -0800 Subject: [PATCH] feat(filechooser): supported file chooser in FF (#70) --- browser_patches/firefox/BUILD_NUMBER | 2 +- .../patches/0001-chore-bootstrap.patch | 217 ++++++++++++++++-- src/firefox/JSHandle.ts | 38 ++- src/firefox/Page.ts | 68 +++++- test/input.spec.js | 8 +- 5 files changed, 299 insertions(+), 34 deletions(-) diff --git a/browser_patches/firefox/BUILD_NUMBER b/browser_patches/firefox/BUILD_NUMBER index 83b33d238d..dd11724042 100644 --- a/browser_patches/firefox/BUILD_NUMBER +++ b/browser_patches/firefox/BUILD_NUMBER @@ -1 +1 @@ -1000 +1001 diff --git a/browser_patches/firefox/patches/0001-chore-bootstrap.patch b/browser_patches/firefox/patches/0001-chore-bootstrap.patch index 72301c37e2..3e780abdd4 100644 --- a/browser_patches/firefox/patches/0001-chore-bootstrap.patch +++ b/browser_patches/firefox/patches/0001-chore-bootstrap.patch @@ -1,12 +1,15 @@ -From 0ad0c75a7109b3db02515806dedfa1dad51f1183 Mon Sep 17 00:00:00 2001 -From: Andrey Lushnikov -Date: Fri, 22 Nov 2019 19:18:05 -0800 +From acb1ee0450ffe7739ed96e556095fb0f1c5d60d0 Mon Sep 17 00:00:00 2001 +From: Pavel +Date: Mon, 25 Nov 2019 13:47:08 -0800 Subject: [PATCH] chore: bootstrap --- browser/installer/allowed-dupes.mn | 5 + browser/installer/package-manifest.in | 5 + - docshell/base/nsDocShell.cpp | 1 + + docshell/base/nsDocShell.cpp | 34 + + docshell/base/nsDocShell.h | 7 + + docshell/base/nsIDocShell.idl | 3 + + dom/html/HTMLInputElement.cpp | 7 + dom/ipc/BrowserChild.cpp | 7 + .../permissions/nsPermissionManager.cpp | 8 +- .../manager/ssl/nsCertOverrideService.cpp | 2 +- @@ -20,21 +23,21 @@ Subject: [PATCH] chore: bootstrap testing/juggler/content/ContentSession.js | 63 ++ testing/juggler/content/FrameTree.js | 232 ++++++ testing/juggler/content/NetworkMonitor.js | 62 ++ - testing/juggler/content/PageAgent.js | 621 ++++++++++++++++ - testing/juggler/content/RuntimeAgent.js | 460 ++++++++++++ + testing/juggler/content/PageAgent.js | 638 +++++++++++++++++ + testing/juggler/content/RuntimeAgent.js | 468 ++++++++++++ testing/juggler/content/ScrollbarManager.js | 85 +++ .../juggler/content/floating-scrollbars.css | 47 ++ testing/juggler/content/hidden-scrollbars.css | 13 + - testing/juggler/content/main.js | 39 ++ + testing/juggler/content/main.js | 39 + testing/juggler/jar.mn | 29 + testing/juggler/moz.build | 15 + .../juggler/protocol/AccessibilityHandler.js | 15 + testing/juggler/protocol/BrowserHandler.js | 66 ++ testing/juggler/protocol/Dispatcher.js | 255 +++++++ testing/juggler/protocol/NetworkHandler.js | 154 ++++ - testing/juggler/protocol/PageHandler.js | 269 +++++++ + testing/juggler/protocol/PageHandler.js | 277 ++++++++ testing/juggler/protocol/PrimitiveTypes.js | 143 ++++ - testing/juggler/protocol/Protocol.js | 660 ++++++++++++++++++ + testing/juggler/protocol/Protocol.js | 669 ++++++++++++++++++ testing/juggler/protocol/RuntimeHandler.js | 41 ++ testing/juggler/protocol/TargetHandler.js | 75 ++ .../statusfilter/nsBrowserStatusFilter.cpp | 12 +- @@ -43,7 +46,7 @@ Subject: [PATCH] chore: bootstrap uriloader/base/nsDocLoader.h | 5 + uriloader/base/nsIWebProgress.idl | 7 +- uriloader/base/nsIWebProgressListener2.idl | 23 + - 39 files changed, 4487 insertions(+), 7 deletions(-) + 42 files changed, 4579 insertions(+), 7 deletions(-) create mode 100644 testing/juggler/BrowserContextManager.js create mode 100644 testing/juggler/Helper.js create mode 100644 testing/juggler/NetworkObserver.js @@ -105,10 +108,26 @@ index 0efb8c4210bf..6695fa1deb70 100644 @RESPATH@/components/TestInterfaceJS.js @RESPATH@/components/TestInterfaceJS.manifest diff --git a/docshell/base/nsDocShell.cpp b/docshell/base/nsDocShell.cpp -index b56ce1764dbb..1f4e7cb24d6f 100644 +index b56ce1764dbb..9e735bd9e185 100644 --- a/docshell/base/nsDocShell.cpp +++ b/docshell/base/nsDocShell.cpp -@@ -1241,6 +1241,7 @@ bool nsDocShell::SetCurrentURI(nsIURI* aURI, nsIRequest* aRequest, +@@ -97,6 +97,7 @@ + #include "nsIDocShellTreeItem.h" + #include "nsIDocShellTreeOwner.h" + #include "mozilla/dom/Document.h" ++#include "mozilla/dom/Element.h" + #include "nsIDocumentLoaderFactory.h" + #include "nsIDOMWindow.h" + #include "nsIEditingSession.h" +@@ -360,6 +361,7 @@ nsDocShell::nsDocShell(BrowsingContext* aBrowsingContext, + mUseStrictSecurityChecks(false), + mObserveErrorPages(true), + mCSSErrorReportingEnabled(false), ++ mFileInputInterceptionEnabled(false), + mAllowAuth(mItemType == typeContent), + mAllowKeywordFixup(false), + mIsOffScreenBrowser(false), +@@ -1241,6 +1243,7 @@ bool nsDocShell::SetCurrentURI(nsIURI* aURI, nsIRequest* aRequest, isSubFrame = mLSHE->GetIsSubFrame(); } @@ -116,6 +135,120 @@ index b56ce1764dbb..1f4e7cb24d6f 100644 if (!isSubFrame && !isRoot) { /* * We don't want to send OnLocationChange notifications when +@@ -3678,6 +3681,37 @@ nsDocShell::GetContentBlockingLog(Promise** aPromise) { + return NS_OK; + } + ++nsDocShell* nsDocShell::GetRootDocShell() { ++ nsCOMPtr rootAsItem; ++ GetInProcessSameTypeRootTreeItem(getter_AddRefs(rootAsItem)); ++ nsCOMPtr rootShell = do_QueryInterface(rootAsItem); ++ return nsDocShell::Cast(rootShell); ++} ++ ++NS_IMETHODIMP ++nsDocShell::GetFileInputInterceptionEnabled(bool* aEnabled) { ++ MOZ_ASSERT(aEnabled); ++ *aEnabled = mFileInputInterceptionEnabled; ++ return NS_OK; ++} ++ ++NS_IMETHODIMP ++nsDocShell::SetFileInputInterceptionEnabled(bool aEnabled) { ++ mFileInputInterceptionEnabled = aEnabled; ++ return NS_OK; ++} ++ ++bool nsDocShell::IsFileInputInterceptionEnabled() { ++ return GetRootDocShell()->mFileInputInterceptionEnabled; ++} ++ ++void nsDocShell::FilePickerShown(mozilla::dom::Element* element) { ++ nsCOMPtr observerService = ++ mozilla::services::GetObserverService(); ++ observerService->NotifyObservers( ++ ToSupports(element), "juggler-file-picker-shown", nullptr); ++} ++ + NS_IMETHODIMP + nsDocShell::GetIsNavigating(bool* aOut) { + *aOut = mIsNavigating; +diff --git a/docshell/base/nsDocShell.h b/docshell/base/nsDocShell.h +index 6338967342ed..3814dd914f1f 100644 +--- a/docshell/base/nsDocShell.h ++++ b/docshell/base/nsDocShell.h +@@ -18,6 +18,7 @@ + #include "mozilla/WeakPtr.h" + + #include "mozilla/dom/BrowsingContext.h" ++#include "mozilla/dom/Element.h" + #include "mozilla/dom/ProfileTimelineMarkerBinding.h" + #include "mozilla/gfx/Matrix.h" + #include "mozilla/dom/ChildSHistory.h" +@@ -469,6 +470,9 @@ class nsDocShell final : public nsDocLoader, + mSkipBrowsingContextDetachOnDestroy = true; + } + ++ bool IsFileInputInterceptionEnabled(); ++ void FilePickerShown(mozilla::dom::Element* element); ++ + // Create a content viewer within this nsDocShell for the given + // `WindowGlobalChild` actor. + nsresult CreateContentViewerForActor( +@@ -1020,6 +1024,8 @@ class nsDocShell final : public nsDocLoader, + + bool CSSErrorReportingEnabled() const { return mCSSErrorReportingEnabled; } + ++ nsDocShell* GetRootDocShell(); ++ + // Handles retrieval of subframe session history for nsDocShell::LoadURI. If a + // load is requested in a subframe of the current DocShell, the subframe + // loadType may need to reflect the loadType of the parent document, or in +@@ -1279,6 +1285,7 @@ class nsDocShell final : public nsDocLoader, + bool mUseStrictSecurityChecks : 1; + bool mObserveErrorPages : 1; + bool mCSSErrorReportingEnabled : 1; ++ bool mFileInputInterceptionEnabled: 1; + bool mAllowAuth : 1; + bool mAllowKeywordFixup : 1; + bool mIsOffScreenBrowser : 1; +diff --git a/docshell/base/nsIDocShell.idl b/docshell/base/nsIDocShell.idl +index 72e125e93065..d88e87188a52 100644 +--- a/docshell/base/nsIDocShell.idl ++++ b/docshell/base/nsIDocShell.idl +@@ -1180,4 +1180,7 @@ interface nsIDocShell : nsIDocShellTreeItem + * nsIWebNavigation.loadURI + */ + [infallible] readonly attribute boolean isNavigating; ++ ++ attribute boolean fileInputInterceptionEnabled; ++ + }; +diff --git a/dom/html/HTMLInputElement.cpp b/dom/html/HTMLInputElement.cpp +index 304c76019486..7cb26cb74a25 100644 +--- a/dom/html/HTMLInputElement.cpp ++++ b/dom/html/HTMLInputElement.cpp +@@ -46,6 +46,7 @@ + #include "nsMappedAttributes.h" + #include "nsIFormControl.h" + #include "mozilla/dom/Document.h" ++#include "nsDocShell.h" + #include "nsIFormControlFrame.h" + #include "nsITextControlFrame.h" + #include "nsIFrame.h" +@@ -734,6 +735,12 @@ nsresult HTMLInputElement::InitFilePicker(FilePickerType aType) { + return NS_ERROR_FAILURE; + } + ++ nsDocShell* docShell = static_cast(win->GetDocShell()); ++ if (docShell && docShell->IsFileInputInterceptionEnabled()) { ++ docShell->FilePickerShown(this); ++ return NS_OK; ++ } ++ + if (IsPopupBlocked()) { + return NS_OK; + } diff --git a/dom/ipc/BrowserChild.cpp b/dom/ipc/BrowserChild.cpp index 6cfb8fcbaa43..3618739a53a9 100644 --- a/dom/ipc/BrowserChild.cpp @@ -1662,10 +1795,10 @@ index 000000000000..2508cce41565 + diff --git a/testing/juggler/content/PageAgent.js b/testing/juggler/content/PageAgent.js new file mode 100644 -index 000000000000..e8db4031620e +index 000000000000..0e47a99eda47 --- /dev/null +++ b/testing/juggler/content/PageAgent.js -@@ -0,0 +1,621 @@ +@@ -0,0 +1,638 @@ +"use strict"; +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const Ci = Components.interfaces; @@ -1693,6 +1826,7 @@ index 000000000000..e8db4031620e + this._enabled = false; + + const docShell = frameTree.mainFrame().docShell(); ++ this._docShell = docShell; + this._initialDPPX = docShell.contentViewer.overrideDPPX; + this._customScrollbars = null; + } @@ -1773,7 +1907,9 @@ index 000000000000..e8db4031620e + if (frame.pendingNavigationId()) + this._onNavigationStarted(frame); + } ++ + this._eventListeners = [ ++ helper.addObserver(this._filePickerShown.bind(this), 'juggler-file-picker-shown'), + helper.addObserver(this._onDOMWindowCreated.bind(this), 'content-document-global-created'), + helper.addEventListener(this._session.mm(), 'DOMContentLoaded', this._onDOMContentLoaded.bind(this)), + helper.addEventListener(this._session.mm(), 'pageshow', this._onLoad.bind(this)), @@ -1787,6 +1923,20 @@ index 000000000000..e8db4031620e + ]; + } + ++ setInterceptFileChooserDialog({enabled}) { ++ this._docShell.fileInputInterceptionEnabled = !!enabled; ++ } ++ ++ _filePickerShown(inputElement) { ++ if (inputElement.ownerGlobal.docShell !== this._docShell) ++ return; ++ const result = this._runtime.rawElementToRemoteObject(inputElement); ++ this._session.emitEvent('Page.fileChooserOpened', { ++ executionContextId: result.executionContextId, ++ element: result.element ++ }); ++ } ++ + _onDOMContentLoaded(event) { + const docShell = event.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); @@ -2289,10 +2439,10 @@ index 000000000000..e8db4031620e + diff --git a/testing/juggler/content/RuntimeAgent.js b/testing/juggler/content/RuntimeAgent.js new file mode 100644 -index 000000000000..2c474230071b +index 000000000000..a8f017a07133 --- /dev/null +++ b/testing/juggler/content/RuntimeAgent.js -@@ -0,0 +1,460 @@ +@@ -0,0 +1,468 @@ +"use strict"; +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); @@ -2384,6 +2534,14 @@ index 000000000000..2c474230071b + this._enabled = false; + } + ++ rawElementToRemoteObject(node) { ++ const executionContext = Array.from(this._executionContexts.values()).find(context => node.ownerDocument == context._domWindow.document); ++ return { ++ executionContextId: executionContext.id(), ++ element: executionContext.rawValueToRemoteObject(node) ++ }; ++ } ++ + _consoleAPICalled({wrappedJSObject}, topic, data) { + const type = consoleLevelToProtocolType[wrappedJSObject.level]; + if (!type) @@ -3533,10 +3691,10 @@ index 000000000000..f5e7e919594b +this.NetworkHandler = NetworkHandler; diff --git a/testing/juggler/protocol/PageHandler.js b/testing/juggler/protocol/PageHandler.js new file mode 100644 -index 000000000000..32fb7e9d928a +index 000000000000..18a2d679e0f4 --- /dev/null +++ b/testing/juggler/protocol/PageHandler.js -@@ -0,0 +1,269 @@ +@@ -0,0 +1,277 @@ +"use strict"; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); @@ -3747,6 +3905,14 @@ index 000000000000..32fb7e9d928a + else + dialog.dismiss(); + } ++ ++ async setInterceptFileChooserDialog(options) { ++ return await this._contentSession.send('Page.setInterceptFileChooserDialog', options); ++ } ++ ++ async handleFileChooser(options) { ++ return await this._contentSession.send('Page.handleFileChooser', options); ++ } +} + +class Dialog { @@ -3957,10 +4123,10 @@ index 000000000000..78b6601b91d0 +this.EXPORTED_SYMBOLS = ['t', 'checkScheme']; diff --git a/testing/juggler/protocol/Protocol.js b/testing/juggler/protocol/Protocol.js new file mode 100644 -index 000000000000..63186502775d +index 000000000000..0b2044e057a4 --- /dev/null +++ b/testing/juggler/protocol/Protocol.js -@@ -0,0 +1,660 @@ +@@ -0,0 +1,669 @@ +const {t, checkScheme} = ChromeUtils.import('chrome://juggler/content/protocol/PrimitiveTypes.js'); + +// Protocol-specific types. @@ -4405,6 +4571,10 @@ index 000000000000..63186502775d + name: t.String, + payload: t.Any, + }, ++ 'fileChooserOpened': { ++ executionContextId: t.String, ++ element: types.RemoteObject ++ }, + }, + + methods: { @@ -4599,6 +4769,11 @@ index 000000000000..63186502775d + promptText: t.Optional(t.String), + }, + }, ++ 'setInterceptFileChooserDialog': { ++ params: { ++ enabled: t.Boolean, ++ }, ++ }, + }, +}; + @@ -4891,5 +5066,5 @@ index 87701f8d2cfe..ae1aa85c019c 100644 + [optional] in unsigned long aFlags); }; -- -2.22.1 +2.17.1 diff --git a/src/firefox/JSHandle.ts b/src/firefox/JSHandle.ts index 4962273aea..cf411bbae4 100644 --- a/src/firefox/JSHandle.ts +++ b/src/firefox/JSHandle.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import * as fs from 'fs'; import * as path from 'path'; import { assert, debugError, helper } from '../helper'; import { ClickOptions, fillFunction, MultiClickOptions, selectFunction, SelectOption } from '../input'; @@ -24,6 +25,7 @@ import Injected from '../injected/injected'; type SelectorRoot = Element | ShadowRoot | Document; import { ExecutionContext } from './ExecutionContext'; import { Frame } from './FrameManager'; +const readFileAsync = helper.promisify(fs.readFile); export class JSHandle { _context: ExecutionContext; @@ -309,13 +311,29 @@ export class ElementHandle extends JSHandle { await this._frame._page.mouse.tripleclick(x, y, options); } - async uploadFile(...filePaths: Array) { - const files = filePaths.map(filePath => path.resolve(filePath)); - await this._session.send('Page.setFileInputFiles', { - frameId: this._frameId, - objectId: this._objectId, - files, - }); + async uploadFile(...files: Array) { + const multiple = await this.evaluate((element: HTMLInputElement) => !!element.multiple); + assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!'); + const blobs = await Promise.all(files.map(path => readFileAsync(path))); + const payloads: FilePayload[] = []; + for (let i = 0; i < files.length; ++i) { + payloads.push({ + name: path.basename(files[i]), + mimeType: 'application/octet-stream', + data: blobs[i].toString('base64') + }); + } + await this.evaluate(async (element: HTMLInputElement, payloads: FilePayload[]) => { + const files = await Promise.all(payloads.map(async (file: FilePayload) => { + const result = await fetch(`data:${file.mimeType};base64,${file.data}`) + return new File([await result.blob()], file.name); + })); + const dt = new DataTransfer(); + for (const file of files) + dt.items.add(file); + element.files = dt.files; + element.dispatchEvent(new Event('input', { 'bubbles': true })); + }, payloads); } async hover() { @@ -410,3 +428,9 @@ function computeQuadCenter(quad) { } return {x: x / 4, y: y / 4}; } + +type FilePayload = { + name: string, + mimeType: string, + data: string +}; diff --git a/src/firefox/Page.ts b/src/firefox/Page.ts index 367e10d85f..69f89ad229 100644 --- a/src/firefox/Page.ts +++ b/src/firefox/Page.ts @@ -35,13 +35,18 @@ export class Page extends EventEmitter { private _eventListeners: RegisteredListener[]; private _viewport: Viewport; private _disconnectPromise: Promise; + private _fileChooserInterceptionIsDisabled = false; + private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>(); - static async create(session, target: Target, defaultViewport: Viewport | null) { + static async create(session: JugglerSession, target: Target, defaultViewport: Viewport | null) { const page = new Page(session, target); await Promise.all([ session.send('Runtime.enable'), session.send('Network.enable'), session.send('Page.enable'), + session.send('Page.setInterceptFileChooserDialog', { enabled: true }).catch(e => { + page._fileChooserInterceptionIsDisabled = true; + }), ]); if (defaultViewport) @@ -68,6 +73,7 @@ export class Page extends EventEmitter { helper.addEventListener(this._session, 'Runtime.console', this._onConsole.bind(this)), helper.addEventListener(this._session, 'Page.dialogOpened', this._onDialogOpened.bind(this)), helper.addEventListener(this._session, 'Page.bindingCalled', this._onBindingCalled.bind(this)), + helper.addEventListener(this._session, 'Page.fileChooserOpened', this._onFileChooserOpened.bind(this)), helper.addEventListener(this._frameManager, FrameManagerEvents.Load, () => this.emit(Events.Page.Load)), helper.addEventListener(this._frameManager, FrameManagerEvents.DOMContentLoaded, () => this.emit(Events.Page.DOMContentLoaded)), helper.addEventListener(this._frameManager, FrameManagerEvents.FrameAttached, frame => this.emit(Events.Page.FrameAttached, frame)), @@ -573,6 +579,36 @@ export class Page extends EventEmitter { isClosed(): boolean { return this._closed; } + + async waitForFileChooser(options: { timeout?: number; } = {}): Promise { + if (this._fileChooserInterceptionIsDisabled) + throw new Error('File chooser handling does not work with multiple connections to the same page'); + const { + timeout = this._timeoutSettings.timeout(), + } = options; + let callback; + const promise = new Promise(x => callback = x); + this._fileChooserInterceptors.add(callback); + return helper.waitWithTimeout(promise, 'waiting for file chooser', timeout).catch(e => { + this._fileChooserInterceptors.delete(callback); + throw e; + }); + } + + async _onFileChooserOpened({executionContextId, element}) { + const context = this._frameManager.executionContextById(executionContextId); + if (!this._fileChooserInterceptors.size) { + this._session.send('Page.handleFileChooser', { action: 'fallback' }).catch(debugError); + return; + } + const handle = createHandle(context, element) as ElementHandle; + const interceptors = Array.from(this._fileChooserInterceptors); + this._fileChooserInterceptors.clear(); + const multiple = await handle.evaluate((element: HTMLInputElement) => !!element.multiple); + const fileChooser = new FileChooser(this, this._session, handle, multiple); + for (const interceptor of interceptors) + interceptor.call(null, fileChooser); + } } export class ConsoleMessage { @@ -637,4 +673,34 @@ export type Viewport = { type MediaFeature = { name: string, value: string +}; + +export class FileChooser { + private _page; Page; + private _client: JugglerSession; + private _element: ElementHandle; + private _multiple: boolean; + private _handled = false; + + constructor(page: Page, client: JugglerSession, element: ElementHandle, multiple: boolean) { + this._page = page; + this._client = client; + this._element = element; + this._multiple = multiple; + } + + isMultiple(): boolean { + return this._multiple; + } + + async accept(filePaths: string[]): Promise { + assert(!this._handled, 'Cannot accept FileChooser which is already handled!'); + this._handled = true; + await this._element.uploadFile(...filePaths); + } + + async cancel(): Promise { + assert(!this._handled, 'Cannot cancel FileChooser which is already handled!'); + this._handled = true; + } } diff --git a/test/input.spec.js b/test/input.spec.js index 834e9b5576..52638e3278 100644 --- a/test/input.spec.js +++ b/test/input.spec.js @@ -38,7 +38,7 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME }); }); - describe.skip(FFOX || WEBKIT)('Page.waitForFileChooser', function() { + describe.skip(WEBKIT)('Page.waitForFileChooser', function() { it('should work when file input is attached to DOM', async({page, server}) => { await page.setContent(``); const [chooser] = await Promise.all([ @@ -97,7 +97,7 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME }); }); - describe.skip(FFOX || WEBKIT)('FileChooser.accept', function() { + describe.skip(WEBKIT)('FileChooser.accept', function() { it('should accept single file', async({page, server}) => { await page.setContent(``); const [chooser] = await Promise.all([ @@ -161,7 +161,7 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME }); }); - describe.skip(FFOX || WEBKIT)('FileChooser.cancel', function() { + describe.skip(WEBKIT)('FileChooser.cancel', function() { it('should cancel dialog', async({page, server}) => { // Consider file chooser canceled if we can summon another one. // There's no reliable way in WebPlatform to see that FileChooser was @@ -191,7 +191,7 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME }); }); - describe.skip(FFOX || WEBKIT)('FileChooser.isMultiple', () => { + describe.skip(WEBKIT)('FileChooser.isMultiple', () => { it('should work for single file pick', async({page, server}) => { await page.setContent(``); const [chooser] = await Promise.all([