From 64d3e83ddfcd831770376babc849ac3814e403f4 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 26 Nov 2019 14:29:21 -0800 Subject: [PATCH] chrome(filechooser): align file chooser implementations (#88) --- docs/api.md | 18 +++++++----- src/chromium/ExecutionContext.ts | 14 +++++---- src/chromium/JSHandle.ts | 33 +++++++++++---------- src/firefox/JSHandle.ts | 46 +++++++++--------------------- src/firefox/Page.ts | 15 ++-------- src/input.ts | 38 +++++++++++++++++++++++- src/webkit/JSHandle.ts | 20 ++++++++----- test/defaultbrowsercontext.spec.js | 37 +++++++++--------------- test/input.spec.js | 2 +- test/requestinterception.spec.js | 4 +-- 10 files changed, 119 insertions(+), 108 deletions(-) diff --git a/docs/api.md b/docs/api.md index 0e8527094a..5e84034786 100644 --- a/docs/api.md +++ b/docs/api.md @@ -270,10 +270,10 @@ * [elementHandle.press(key[, options])](#elementhandlepresskey-options) * [elementHandle.screenshot([options])](#elementhandlescreenshotoptions) * [elementHandle.select(...values)](#elementhandleselectvalues) + * [elementHandle.setInputFiles(...files)](#elementhandlesetinputfilesfiles) * [elementHandle.toString()](#elementhandletostring) * [elementHandle.tripleclick([options])](#elementhandletripleclickoptions) * [elementHandle.type(text[, options])](#elementhandletypetext-options) - * [elementHandle.uploadFile(...filePaths)](#elementhandleuploadfilefilepaths) - [class: Request](#class-request) * [request.failure()](#requestfailure) * [request.frame()](#requestframe) @@ -3541,6 +3541,15 @@ handle.select('red', 'green', 'blue'); handle.select({ value: 'blue' }, { index: 2 }, 'red'); ``` +#### elementHandle.setInputFiles(...files) +- `...files` <...[string]|[Object]> Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then they are resolved relative to the [current working directory](https://nodejs.org/api/process.html#process_process_cwd). + - `name` <[string]> <[File]> name + - `type` <[string]> <[File]> type + - `data` <[string]> Base64-encoded data +- returns: <[Promise]> + +This method expects `elementHandle` to point to an [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). + #### elementHandle.toString() - returns: <[string]> @@ -3583,12 +3592,6 @@ await elementHandle.type('some text'); await elementHandle.press('Enter'); ``` -#### elementHandle.uploadFile(...filePaths) -- `...filePaths` <...[string]> Sets the value of the file input to these paths. If some of the `filePaths` are relative paths, then they are resolved relative to the [current working directory](https://nodejs.org/api/process.html#process_process_cwd). -- returns: <[Promise]> - -This method expects `elementHandle` to point to an [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). - ### class: Request Whenever the page sends a request, such as for a network resource, the following events are emitted by playwright's page: @@ -3883,6 +3886,7 @@ TimeoutError is emitted whenever certain operations are terminated due to timeou [Element]: https://developer.mozilla.org/en-US/docs/Web/API/element "Element" [Error]: https://nodejs.org/api/errors.html#errors_class_error "Error" [ExecutionContext]: #class-executioncontext "ExecutionContext" +[File]: #class-file "https://developer.mozilla.org/en-US/docs/Web/API/File" [FileChooser]: #class-filechooser "FileChooser" [Frame]: #class-frame "Frame" [JSHandle]: #class-jshandle "JSHandle" diff --git a/src/chromium/ExecutionContext.ts b/src/chromium/ExecutionContext.ts index ed56e6c4ac..74adf2ce81 100644 --- a/src/chromium/ExecutionContext.ts +++ b/src/chromium/ExecutionContext.ts @@ -151,17 +151,21 @@ export class ExecutionContext implements types.EvaluationContext { } } + async _adoptBackendNodeId(backendNodeId: Protocol.DOM.BackendNodeId) { + const {object} = await this._client.send('DOM.resolveNode', { + backendNodeId, + executionContextId: this._contextId, + }); + return createJSHandle(this, object) as ElementHandle; + } + async _adoptElementHandle(elementHandle: ElementHandle): Promise { assert(elementHandle.executionContext() !== this, 'Cannot adopt handle that already belongs to this execution context'); assert(this._frame, 'Cannot adopt handle without a Frame'); const nodeInfo = await this._client.send('DOM.describeNode', { objectId: elementHandle._remoteObject.objectId, }); - const {object} = await this._client.send('DOM.resolveNode', { - backendNodeId: nodeInfo.node.backendNodeId, - executionContextId: this._contextId, - }); - return createJSHandle(this, object) as ElementHandle; + return this._adoptBackendNodeId(nodeInfo.node.backendNodeId); } _injected(): Promise { diff --git a/src/chromium/JSHandle.ts b/src/chromium/JSHandle.ts index 0fbd868522..a587b52451 100644 --- a/src/chromium/JSHandle.ts +++ b/src/chromium/JSHandle.ts @@ -15,10 +15,10 @@ * limitations under the License. */ -import * as path from 'path'; -import * as types from '../types'; import { assert, debugError, helper } from '../helper'; -import { ClickOptions, Modifier, MultiClickOptions, PointerActionOptions, SelectOption, selectFunction, fillFunction } from '../input'; +import Injected from '../injected/injected'; +import * as input from '../input'; +import * as types from '../types'; import { CDPSession } from './Connection'; import { ExecutionContext } from './ExecutionContext'; import { Frame } from './Frame'; @@ -26,7 +26,6 @@ import { FrameManager } from './FrameManager'; import { Page } from './Page'; import { Protocol } from './protocol'; import { releaseObject, valueFromRemoteObject } from './protocolHelper'; -import Injected from '../injected/injected'; type SelectorRoot = Element | ShadowRoot | Document; @@ -236,7 +235,7 @@ export class ElementHandle extends JSHandle { return { point, scrollX, scrollY }; } - async _performPointerAction(action: (point: Point) => Promise, options?: PointerActionOptions): Promise { + async _performPointerAction(action: (point: Point) => Promise, options?: input.PointerActionOptions): Promise { await this._scrollIntoViewIfNeeded(); let point: Point; if (options && options.relativePoint) { @@ -259,7 +258,7 @@ export class ElementHandle extends JSHandle { await this._scrollIntoViewIfNeeded(); point = await this._clickablePoint(); } - let restoreModifiers: Modifier[] | undefined; + let restoreModifiers: input.Modifier[] | undefined; if (options && options.modifiers) restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); await action(point); @@ -289,23 +288,23 @@ export class ElementHandle extends JSHandle { })); } - hover(options?: PointerActionOptions): Promise { + hover(options?: input.PointerActionOptions): Promise { return this._performPointerAction(point => this._page.mouse.move(point.x, point.y), options); } - click(options?: ClickOptions): Promise { + click(options?: input.ClickOptions): Promise { return this._performPointerAction(point => this._page.mouse.click(point.x, point.y, options), options); } - dblclick(options?: MultiClickOptions): Promise { + dblclick(options?: input.MultiClickOptions): Promise { return this._performPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options); } - tripleclick(options?: MultiClickOptions): Promise { + tripleclick(options?: input.MultiClickOptions): Promise { return this._performPointerAction(point => this._page.mouse.tripleclick(point.x, point.y, options), options); } - async select(...values: (string | ElementHandle | SelectOption)[]): Promise { + async select(...values: (string | ElementHandle | input.SelectOption)[]): Promise { const options = values.map(value => typeof value === 'object' ? value : { value }); for (const option of options) { if (option instanceof ElementHandle) @@ -317,22 +316,22 @@ export class ElementHandle extends JSHandle { if (option.index !== undefined) assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"'); } - return this.evaluate(selectFunction, ...options); + return this.evaluate(input.selectFunction, ...options); } async fill(value: string): Promise { assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"'); - const error = await this.evaluate(fillFunction); + const error = await this.evaluate(input.fillFunction); if (error) throw new Error(error); await this.focus(); await this._page.keyboard.sendCharacters(value); } - async uploadFile(...filePaths: string[]) { - const files = filePaths.map(filePath => path.resolve(filePath)); - const objectId = this._remoteObject.objectId; - await this._client.send('DOM.setFileInputFiles', { objectId, files }); + async setInputFiles(...files: (string|input.FilePayload)[]) { + const multiple = await this.evaluate((element: HTMLInputElement) => !!element.multiple); + assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!'); + await this.evaluate(input.setFileInputFunction, await input.loadFiles(files)); } async focus() { diff --git a/src/firefox/JSHandle.ts b/src/firefox/JSHandle.ts index 8b3c87069b..4ad47fdfe9 100644 --- a/src/firefox/JSHandle.ts +++ b/src/firefox/JSHandle.ts @@ -16,16 +16,15 @@ */ import * as fs from 'fs'; -import * as path from 'path'; -import * as types from '../types'; import { assert, debugError, helper } from '../helper'; -import { ClickOptions, fillFunction, MultiClickOptions, selectFunction, SelectOption } from '../input'; -import { JugglerSession } from './Connection'; import Injected from '../injected/injected'; - -type SelectorRoot = Element | ShadowRoot | Document; +import * as input from '../input'; +import * as types from '../types'; +import { JugglerSession } from './Connection'; import { ExecutionContext } from './ExecutionContext'; import { Frame } from './FrameManager'; + +type SelectorRoot = Element | ShadowRoot | Document; const readFileAsync = helper.promisify(fs.readFile); export class JSHandle { @@ -294,47 +293,28 @@ export class ElementHandle extends JSHandle { throw new Error(error); } - async click(options?: ClickOptions) { + async click(options?: input.ClickOptions) { await this._scrollIntoViewIfNeeded(); const {x, y} = await this._clickablePoint(); await this._frame._page.mouse.click(x, y, options); } - async dblclick(options?: MultiClickOptions): Promise { + async dblclick(options?: input.MultiClickOptions): Promise { await this._scrollIntoViewIfNeeded(); const {x, y} = await this._clickablePoint(); await this._frame._page.mouse.dblclick(x, y, options); } - async tripleclick(options?: MultiClickOptions): Promise { + async tripleclick(options?: input.MultiClickOptions): Promise { await this._scrollIntoViewIfNeeded(); const {x, y} = await this._clickablePoint(); await this._frame._page.mouse.tripleclick(x, y, options); } - async uploadFile(...files: Array) { + async setInputFiles(...files: (string|input.FilePayload)[]) { 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); + await this.evaluate(input.setFileInputFunction, await input.loadFiles(files)); } async hover() { @@ -357,7 +337,7 @@ export class ElementHandle extends JSHandle { await this._frame._page.keyboard.press(key, options); } - async select(...values: (string | ElementHandle | SelectOption)[]): Promise { + async select(...values: (string | ElementHandle | input.SelectOption)[]): Promise { const options = values.map(value => typeof value === 'object' ? value : { value }); for (const option of options) { if (option instanceof ElementHandle) @@ -369,12 +349,12 @@ export class ElementHandle extends JSHandle { if (option.index !== undefined) assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"'); } - return this.evaluate(selectFunction, ...options); + return this.evaluate(input.selectFunction, ...options); } async fill(value: string): Promise { assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"'); - const error = await this.evaluate(fillFunction); + const error = await this.evaluate(input.fillFunction); if (error) throw new Error(error); await this.focus(); diff --git a/src/firefox/Page.ts b/src/firefox/Page.ts index 33dc91c795..145d43cc80 100644 --- a/src/firefox/Page.ts +++ b/src/firefox/Page.ts @@ -558,7 +558,7 @@ export class Page extends EventEmitter { 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); + const fileChooser = new FileChooser(handle, multiple); for (const interceptor of interceptors) interceptor.call(null, fileChooser); } @@ -623,21 +623,12 @@ export type Viewport = { hasTouch?: boolean; } -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; + constructor(element: ElementHandle, multiple: boolean) { this._element = element; this._multiple = multiple; } @@ -649,7 +640,7 @@ export class FileChooser { async accept(filePaths: string[]): Promise { assert(!this._handled, 'Cannot accept FileChooser which is already handled!'); this._handled = true; - await this._element.uploadFile(...filePaths); + await this._element.setInputFiles(...filePaths); } async cancel(): Promise { diff --git a/src/input.ts b/src/input.ts index be671fe82f..0a8f795155 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1,8 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { assert } from './helper'; +import * as fs from 'fs'; +import * as path from 'path'; +import { assert, helper } from './helper'; import * as keyboardLayout from './USKeyboardLayout'; +const readFileAsync = helper.promisify(fs.readFile); export type Modifier = 'Alt' | 'Control' | 'Meta' | 'Shift'; export type Button = 'left' | 'right' | 'middle'; @@ -344,5 +347,38 @@ export const fillFunction = (element: HTMLElement) => { return false; }; +export const loadFiles = async (items: (string|FilePayload)[]): Promise => { + return Promise.all(items.map(async item => { + if (typeof item === 'string') { + const file: FilePayload = { + name: path.basename(item), + type: 'application/octet-stream', + data: (await readFileAsync(item)).toString('base64') + }; + return file; + } else { + return item as FilePayload; + } + })); +} + +export const setFileInputFunction = async (element: HTMLInputElement, payloads: FilePayload[]) => { + const files = await Promise.all(payloads.map(async (file: FilePayload) => { + const result = await fetch(`data:${file.type};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 })); +}; + +export type FilePayload = { + name: string, + type: string, + data: string +}; + export const mediaTypes = new Set(['screen', 'print']); export const mediaColorSchemes = new Set(['dark', 'light', 'no-preference']); diff --git a/src/webkit/JSHandle.ts b/src/webkit/JSHandle.ts index 3463881ad1..1cc4506b64 100644 --- a/src/webkit/JSHandle.ts +++ b/src/webkit/JSHandle.ts @@ -17,7 +17,7 @@ import * as fs from 'fs'; import { assert, debugError, helper } from '../helper'; -import { ClickOptions, MultiClickOptions, selectFunction, SelectOption, fillFunction } from '../input'; +import * as input from '../input'; import { TargetSession } from './Connection'; import { ExecutionContext } from './ExecutionContext'; import { FrameManager } from './FrameManager'; @@ -217,25 +217,25 @@ export class ElementHandle extends JSHandle { await this._page.mouse.move(x, y); } - async click(options?: ClickOptions): Promise { + async click(options?: input.ClickOptions): Promise { await this._scrollIntoViewIfNeeded(); const {x, y} = await this._clickablePoint(); await this._page.mouse.click(x, y, options); } - async dblclick(options?: MultiClickOptions): Promise { + async dblclick(options?: input.MultiClickOptions): Promise { await this._scrollIntoViewIfNeeded(); const {x, y} = await this._clickablePoint(); await this._page.mouse.dblclick(x, y, options); } - async tripleclick(options?: MultiClickOptions): Promise { + async tripleclick(options?: input.MultiClickOptions): Promise { await this._scrollIntoViewIfNeeded(); const {x, y} = await this._clickablePoint(); await this._page.mouse.tripleclick(x, y, options); } - async select(...values: (string | ElementHandle | SelectOption)[]): Promise { + async select(...values: (string | ElementHandle | input.SelectOption)[]): Promise { const options = values.map(value => typeof value === 'object' ? value : { value }); for (const option of options) { if (option instanceof ElementHandle) @@ -247,18 +247,24 @@ export class ElementHandle extends JSHandle { if (option.index !== undefined) assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"'); } - return this.evaluate(selectFunction, ...options); + return this.evaluate(input.selectFunction, ...options); } async fill(value: string): Promise { assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"'); - const error = await this.evaluate(fillFunction); + const error = await this.evaluate(input.fillFunction); if (error) throw new Error(error); await this.focus(); await this._page.keyboard.sendCharacters(value); } + async setInputFiles(...files: (string|input.FilePayload)[]) { + const multiple = await this.evaluate((element: HTMLInputElement) => !!element.multiple); + assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!'); + await this.evaluate(input.setFileInputFunction, await input.loadFiles(files)); + } + async focus() { await this.evaluate(element => element.focus()); } diff --git a/test/defaultbrowsercontext.spec.js b/test/defaultbrowsercontext.spec.js index c55792263d..1b0ab310f6 100644 --- a/test/defaultbrowsercontext.spec.js +++ b/test/defaultbrowsercontext.spec.js @@ -19,7 +19,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p const {it, fit, xit} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; - describe.skip(FFOX)('DefaultBrowserContext', function() { + describe('DefaultBrowserContext', function() { beforeEach(async state => { state.browser = await playwright.launch(defaultBrowserOptions); state.page = await state.browser.newPage(); @@ -34,7 +34,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p await page.evaluate(() => { document.cookie = 'username=John Doe'; }); - expect(await page.cookies()).toEqual([{ + expect(await page.browserContext().cookies()).toEqual([{ name: 'username', value: 'John Doe', domain: 'localhost', @@ -47,14 +47,15 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p sameSite: 'None', }]); }); - it.skip(WEBKIT)('page.setCookie() should work', async({page, server}) => { + it.skip(WEBKIT)('context.setCookies() should work', async({page, server}) => { await page.goto(server.EMPTY_PAGE); - await page.setCookie({ + await page.browserContext().setCookies([{ + url: server.EMPTY_PAGE, name: 'username', value: 'John Doe' - }); + }]); expect(await page.evaluate(() => document.cookie)).toBe('username=John Doe'); - expect(await page.cookies()).toEqual([{ + expect(await page.browserContext().cookies()).toEqual([{ name: 'username', value: 'John Doe', domain: 'localhost', @@ -67,30 +68,20 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p sameSite: 'None', }]); }); - it.skip(WEBKIT)('page.deleteCookie() should work', async({page, server}) => { + it.skip(WEBKIT)('context.clearCookies() should work', async({page, server}) => { await page.goto(server.EMPTY_PAGE); - await page.setCookie({ + await page.browserContext().setCookies([{ + url: server.EMPTY_PAGE, name: 'cookie1', value: '1' }, { + url: server.EMPTY_PAGE, name: 'cookie2', value: '2' - }); - expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); - await page.deleteCookie({name: 'cookie2'}); - expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); - expect(await page.cookies()).toEqual([{ - name: 'cookie1', - value: '1', - domain: 'localhost', - path: '/', - expires: -1, - size: 8, - httpOnly: false, - secure: false, - session: true, - sameSite: 'None', }]); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); + await page.browserContext().clearCookies(); + expect(await page.evaluate('document.cookie')).toBe(''); }); }); }; diff --git a/test/input.spec.js b/test/input.spec.js index 52638e3278..0810a2f761 100644 --- a/test/input.spec.js +++ b/test/input.spec.js @@ -27,7 +27,7 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME await page.goto(server.PREFIX + '/input/fileupload.html'); const filePath = path.relative(process.cwd(), FILE_TO_UPLOAD); const input = await page.$('input'); - await input.uploadFile(filePath); + await input.setInputFiles(filePath); expect(await page.evaluate(e => e.files[0].name, input)).toBe('file-to-upload.txt'); expect(await page.evaluate(e => { const reader = new FileReader(); diff --git a/test/requestinterception.spec.js b/test/requestinterception.spec.js index 22c10e3f4c..6232820ce3 100644 --- a/test/requestinterception.spec.js +++ b/test/requestinterception.spec.js @@ -102,10 +102,10 @@ module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { expect(requests[1].url()).toContain('/one-style.css'); expect(requests[1].headers().referer).toContain('/one-style.html'); }); - it('should properly return navigation response when URL has cookies', async({page, server}) => { + it('should properly return navigation response when URL has cookies', async({context, page, server}) => { // Setup cookie. await page.goto(server.EMPTY_PAGE); - await page.setCookie({ name: 'foo', value: 'bar'}); + await context.setCookies([{ url: server.EMPTY_PAGE, name: 'foo', value: 'bar'}]); // Setup request interception. await page.interception.enable();