diff --git a/javascript/node/selenium-webdriver/bidi/cookieFilter.js b/javascript/node/selenium-webdriver/bidi/cookieFilter.js new file mode 100644 index 0000000000000..feade4a2a5f75 --- /dev/null +++ b/javascript/node/selenium-webdriver/bidi/cookieFilter.js @@ -0,0 +1,76 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +const { SameSite } = require('./networkTypes') + +class CookieFilter { + #map = new Map() + + name(name) { + this.#map.set('name', name) + return this + } + + value(value) { + this.#map.set('value', Object.fromEntries(value.asMap())) + return this + } + + domain(domain) { + this.#map.set('domain', domain) + return this + } + + path(path) { + this.#map.set('path', path) + return this + } + + size(size) { + this.#map.set('size', size) + return this + } + + httpOnly(httpOnly) { + this.#map.set('httpOnly', httpOnly) + return this + } + + secure(secure) { + this.#map.set('secure', secure) + return this + } + + sameSite(sameSite) { + if (!(sameSite instanceof SameSite)) { + throw new Error(`Params must be a value in SameSite. Received:'${sameSite}'`) + } + this.#map.set('sameSite', sameSite) + return this + } + + expiry(expiry) { + this.#map.set('expiry', expiry) + return this + } + + asMap() { + return this.#map + } +} + +module.exports = { CookieFilter } diff --git a/javascript/node/selenium-webdriver/bidi/networkTypes.js b/javascript/node/selenium-webdriver/bidi/networkTypes.js index fea5a9bd704cc..26322477a1456 100644 --- a/javascript/node/selenium-webdriver/bidi/networkTypes.js +++ b/javascript/node/selenium-webdriver/bidi/networkTypes.js @@ -17,6 +17,47 @@ const { NavigationInfo } = require('./browsingContextTypes') +const SameSite = { + STRICT: 'strict', + LAX: 'lax', + NONE: 'none', + + findByName(name) { + return ( + Object.values(this).find((type) => { + return typeof type === 'string' && name.toLowerCase() === type.toLowerCase() + }) || null + ) + }, +} + +class BytesValue { + static Type = { + STRING: 'string', + BASE64: 'base64', + } + + constructor(type, value) { + this._type = type + this._value = value + } + + get type() { + return this._type + } + + get value() { + return this._value + } + + asMap() { + const map = new Map() + map.set('type', this._type) + map.set('value', this._value) + return map + } +} + class Header { constructor(name, value, binaryValue) { this._name = name @@ -38,10 +79,9 @@ class Header { } class Cookie { - constructor(name, value, binaryValue, domain, path, expires, size, httpOnly, secure, sameSite) { + constructor(name, value, domain, path, size, httpOnly, secure, sameSite, expires) { this._name = name this._value = value - this._binaryValue = binaryValue this._domain = domain this._path = path this._expires = expires @@ -59,10 +99,6 @@ class Cookie { return this._value } - get binaryValue() { - return this._binaryValue - } - get domain() { return this._domain } @@ -201,10 +237,9 @@ class RequestData { let secure = cookie.secure let sameSite = cookie.sameSite let value = 'value' in cookie ? cookie.value : null - let binaryValue = 'binaryValue' in cookie ? cookie.binaryValue : null let expires = 'expires' in cookie ? cookie.expires : null - this._cookies.push(new Cookie(name, value, binaryValue, domain, path, expires, size, httpOnly, secure, sameSite)) + this._cookies.push(new Cookie(name, value, domain, path, size, httpOnly, secure, sameSite, expires)) }) this._headersSize = headersSize this._bodySize = bodySize @@ -453,4 +488,4 @@ class ResponseStarted extends BaseParameters { } } -module.exports = { BeforeRequestSent, ResponseStarted, FetchError } +module.exports = { BytesValue, Cookie, SameSite, BeforeRequestSent, ResponseStarted, FetchError } diff --git a/javascript/node/selenium-webdriver/bidi/partialCookie.js b/javascript/node/selenium-webdriver/bidi/partialCookie.js new file mode 100644 index 0000000000000..70023c9b52421 --- /dev/null +++ b/javascript/node/selenium-webdriver/bidi/partialCookie.js @@ -0,0 +1,62 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +class PartialCookie { + #map = new Map() + + constructor(name, value, domain) { + this.#map.set('name', name) + this.#map.set('value', Object.fromEntries(value.asMap())) + this.#map.set('domain', domain) + } + + path(path) { + this.#map.set('path', path) + return this + } + + size(size) { + this.#map.set('size', size) + return this + } + + httpOnly(httpOnly) { + this.#map.set('httpOnly', httpOnly) + return this + } + + secure(secure) { + this.#map.set('secure', secure) + return this + } + + sameSite(sameSite) { + this.#map.set('sameSite', sameSite) + return this + } + + expiry(expiry) { + this.#map.set('expiry', expiry) + return this + } + + asMap() { + return this.#map + } +} + +module.exports = { PartialCookie } diff --git a/javascript/node/selenium-webdriver/bidi/partitionDescriptor.js b/javascript/node/selenium-webdriver/bidi/partitionDescriptor.js new file mode 100644 index 0000000000000..10654458dd86e --- /dev/null +++ b/javascript/node/selenium-webdriver/bidi/partitionDescriptor.js @@ -0,0 +1,69 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +Type = { + CONTEXT: 'context', + STORAGE_KEY: 'storageKey', +} + +class PartitionDescriptor { + #type + + constructor(type) { + this.#type = type + } +} + +class BrowsingContextPartitionDescriptor extends PartitionDescriptor { + #context = null + + constructor(context) { + super(Type.CONTEXT) + this.#context = context + } + + asMap() { + const map = new Map() + map.set('type', Type.CONTEXT) + map.set('context', this.#context) + return map + } +} + +class StorageKeyPartitionDescriptor extends PartitionDescriptor { + #map = new Map() + + constructor() { + super(Type.STORAGE_KEY) + } + + userContext(userContext) { + this.#map.set('userContext', userContext) + return this + } + + sourceOrigin(sourceOrigin) { + this.#map.set('sourceOrigin', sourceOrigin) + return this + } + + asMap() { + return this.#map + } +} + +module.exports = { BrowsingContextPartitionDescriptor, StorageKeyPartitionDescriptor } diff --git a/javascript/node/selenium-webdriver/bidi/partitionKey.js b/javascript/node/selenium-webdriver/bidi/partitionKey.js new file mode 100644 index 0000000000000..315728e82ee87 --- /dev/null +++ b/javascript/node/selenium-webdriver/bidi/partitionKey.js @@ -0,0 +1,36 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +class PartitionKey { + #userContext + #sourceOrigin + + constructor(userContext, sourceOrigin) { + this.#userContext = userContext + this.#sourceOrigin = sourceOrigin + } + + get userContext() { + return this.#userContext + } + + get sourceOrigin() { + return this.#sourceOrigin + } +} + +module.exports = { PartitionKey } diff --git a/javascript/node/selenium-webdriver/bidi/storage.js b/javascript/node/selenium-webdriver/bidi/storage.js new file mode 100644 index 0000000000000..35a5c35731d4d --- /dev/null +++ b/javascript/node/selenium-webdriver/bidi/storage.js @@ -0,0 +1,171 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +const { CookieFilter } = require('./cookieFilter') +const { BrowsingContextPartitionDescriptor, StorageKeyPartitionDescriptor } = require('./partitionDescriptor') +const { PartitionKey } = require('./partitionKey') +const { PartialCookie } = require('./partialCookie') +const { Cookie } = require('./networkTypes') + +class Storage { + constructor(driver) { + this._driver = driver + } + + async init() { + if (!(await this._driver.getCapabilities()).get('webSocketUrl')) { + throw Error('WebDriver instance must support BiDi protocol') + } + + this.bidi = await this._driver.getBidi() + } + + async getCookies(filter = undefined, partition = undefined) { + if (filter !== undefined && !(filter instanceof CookieFilter)) { + throw new Error(`Params must be an instance of CookieFilter. Received:'${filter}'`) + } + + if ( + partition !== undefined && + !(partition instanceof (BrowsingContextPartitionDescriptor || StorageKeyPartitionDescriptor)) + ) { + throw new Error(`Params must be an instance of PartitionDescriptor. Received:'${partition}'`) + } + + const command = { + method: 'storage.getCookies', + params: { + filter: filter ? Object.fromEntries(filter.asMap()) : undefined, + partition: partition ? Object.fromEntries(partition.asMap()) : undefined, + }, + } + + let response = await this.bidi.send(command) + + let cookies = [] + response.result.cookies.forEach((cookie) => { + cookies.push( + new Cookie( + cookie.name, + cookie.value, + cookie.domain, + cookie.path, + cookie.size, + cookie.httpOnly, + cookie.secure, + cookie.sameSite, + cookie.expiry, + ), + ) + }) + + if (response.result.hasOwnProperty('partitionKey')) { + if ( + response.result.partitionKey.hasOwnProperty('userContext') && + response.result.partitionKey.hasOwnProperty('sourceOrigin') + ) { + let partitionKey = new PartitionKey( + response.result.partitionKey.userContext, + response.result.partitionKey.sourceOrigin, + ) + return { cookies, partitionKey } + } + + return { cookies } + } + } + + async setCookie(cookie, partition = undefined) { + if (!(cookie instanceof PartialCookie)) { + throw new Error(`Params must be an instance of PartialCookie. Received:'${cookie}'`) + } + + if ( + partition !== undefined && + !(partition instanceof (BrowsingContextPartitionDescriptor || StorageKeyPartitionDescriptor)) + ) { + throw new Error(`Params must be an instance of PartitionDescriptor. Received:'${partition}'`) + } + + const command = { + method: 'storage.setCookie', + params: { + cookie: cookie ? Object.fromEntries(cookie.asMap()) : undefined, + partition: partition ? Object.fromEntries(partition.asMap()) : undefined, + }, + } + + let response = await this.bidi.send(command) + + if (response.result.hasOwnProperty('partitionKey')) { + if ( + response.result.partitionKey.hasOwnProperty('userContext') && + response.result.partitionKey.hasOwnProperty('sourceOrigin') + ) { + let partitionKey = new PartitionKey( + response.result.partitionKey.userContext, + response.result.partitionKey.sourceOrigin, + ) + return partitionKey + } + } + } + + async deleteCookies(cookieFilter = undefined, partition = undefined) { + if (cookieFilter !== undefined && !(cookieFilter instanceof CookieFilter)) { + throw new Error(`Params must be an instance of CookieFilter. Received:'${cookieFilter}'`) + } + + if ( + partition !== undefined && + !(partition instanceof (BrowsingContextPartitionDescriptor || StorageKeyPartitionDescriptor)) + ) { + throw new Error(`Params must be an instance of PartitionDescriptor. Received:'${partition}'`) + } + + const command = { + method: 'storage.deleteCookies', + params: { + filter: cookieFilter ? Object.fromEntries(cookieFilter.asMap()) : undefined, + partition: partition ? Object.fromEntries(partition.asMap()) : undefined, + }, + } + + let response = await this.bidi.send(command) + + if (response.result.hasOwnProperty('partitionKey')) { + if ( + response.result.partitionKey.hasOwnProperty('userContext') && + response.result.partitionKey.hasOwnProperty('sourceOrigin') + ) { + let partitionKey = new PartitionKey( + response.result.partitionKey.userContext, + response.result.partitionKey.sourceOrigin, + ) + return partitionKey + } + } + } +} + +async function getStorageInstance(driver) { + let instance = new Storage(driver) + await instance.init() + return instance +} + +module.exports = getStorageInstance diff --git a/javascript/node/selenium-webdriver/test/bidi/storage_test.js b/javascript/node/selenium-webdriver/test/bidi/storage_test.js new file mode 100644 index 0000000000000..1c20b83c9c95e --- /dev/null +++ b/javascript/node/selenium-webdriver/test/bidi/storage_test.js @@ -0,0 +1,253 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +'use strict' + +const assert = require('assert') +require('../../lib/test/fileserver') +const firefox = require('../../firefox') +const { ignore, suite } = require('../../lib/test') +const { Browser } = require('../..') +const Storage = require('../../bidi/storage') +const fileserver = require('../../lib/test/fileserver') +const { CookieFilter } = require('../../bidi/cookieFilter') +const { BytesValue, SameSite } = require('../../bidi/networkTypes') +const { BrowsingContextPartitionDescriptor } = require('../../bidi/partitionDescriptor') +const { PartialCookie } = require('../../bidi/partialCookie') + +suite( + function (env) { + describe('BiDi Storage', function () { + let driver + + beforeEach(async function () { + driver = await env.builder().setFirefoxOptions(new firefox.Options().enableBidi()).build() + await driver.get(fileserver.Pages.ajaxyPage) + await driver.manage().deleteAllCookies() + return assertHasCookies() + }) + + afterEach(function () { + return driver.quit() + }) + + xit('can get cookie by name', async function () { + const cookie = createCookieSpec() + + await driver.manage().addCookie(cookie) + + const cookieFilter = new CookieFilter() + .name(cookie.name) + .value(new BytesValue(BytesValue.Type.STRING, cookie.value)) + + const storage = await Storage(driver) + const result = await storage.getCookies(cookieFilter) + + assert.strictEqual(result.cookies[0].value.value, cookie.value) + }) + + xit('can get cookie in default user context', async function () { + const windowHandle = await driver.getWindowHandle() + const cookie = createCookieSpec() + + await driver.manage().addCookie(cookie) + + const cookieFilter = new CookieFilter() + .name(cookie.name) + .value(new BytesValue(BytesValue.Type.STRING, cookie.value)) + + await driver.switchTo().newWindow('window') + + const descriptor = new BrowsingContextPartitionDescriptor(await driver.getWindowHandle()) + + const storage = await Storage(driver) + const resultAfterSwitchingContext = await storage.getCookies(cookieFilter, descriptor) + + assert.strictEqual(resultAfterSwitchingContext.cookies[0].value.value, cookie.value) + + await driver.switchTo().window(windowHandle) + + const descriptorAfterSwitchingBack = new BrowsingContextPartitionDescriptor(await driver.getWindowHandle()) + + const result = await storage.getCookies(cookieFilter, descriptorAfterSwitchingBack) + + assert.strictEqual(result.cookies[0].value.value, cookie.value) + + const partitionKey = result.partitionKey + + assert.notEqual(partitionKey.userContext, null) + assert.notEqual(partitionKey.sourceOrigin, null) + assert.strictEqual(partitionKey.userContext, 'default') + }) + + xit('can add cookie', async function () { + const cookie = createCookieSpec() + + const storage = await Storage(driver) + + await storage.setCookie( + new PartialCookie(cookie.name, new BytesValue(BytesValue.Type.STRING, cookie.value), fileserver.whereIs('/')), + ) + + const cookieFilter = new CookieFilter() + .name(cookie.name) + .value(new BytesValue(BytesValue.Type.STRING, cookie.value)) + + const result = await storage.getCookies(cookieFilter) + + assert.strictEqual(result.cookies[0].value.value, cookie.value) + }) + + xit('can add and get cookie with all parameters', async function () { + const cookie = createCookieSpec() + + const storage = await Storage(driver) + + const now = Date.now() + const oneHourInMillis = 3600 * 1000 + const expiry = now + oneHourInMillis + + const partitionDescriptor = new BrowsingContextPartitionDescriptor(await driver.getWindowHandle()) + + await storage.setCookie( + new PartialCookie(cookie.name, new BytesValue(BytesValue.Type.STRING, cookie.value), fileserver.whereIs('/')) + .path('/ajaxy_page.html') + .size(100) + .httpOnly(true) + .secure(false) + .sameSite(SameSite.LAX) + .expiry(expiry), + partitionDescriptor, + ) + + const cookieFilter = new CookieFilter() + .name(cookie.name) + .value(new BytesValue(BytesValue.Type.STRING, cookie.value)) + + const result = await storage.getCookies(cookieFilter) + + const resultCookie = result.cookies[0] + + assert.strictEqual(resultCookie.name, cookie.name) + assert.strictEqual(resultCookie.value.value, cookie.value) + assert.strictEqual(resultCookie.domain, fileserver.whereIs('/')) + assert.strictEqual(resultCookie.path, '/ajaxy_page.html') + assert.strictEqual(resultCookie.size > 0, true) + assert.strictEqual(resultCookie.httpOnly, true) + assert.strictEqual(resultCookie.secure, false) + assert.strictEqual(resultCookie.sameSite, SameSite.LAX) + assert.strictEqual(resultCookie.expires, expiry) + }) + + xit('can get all cookies', async function () { + const cookie1 = createCookieSpec() + const cookie2 = createCookieSpec() + + await driver.manage().addCookie(cookie1) + await driver.manage().addCookie(cookie2) + + const storage = await Storage(driver) + const result = await storage.getCookies() + + assert.strictEqual(result.cookies[0].value.value, cookie1.value) + assert.strictEqual(result.cookies[1].value.value, cookie2.value) + }) + + xit('can delete all cookies', async function () { + const cookie1 = createCookieSpec() + const cookie2 = createCookieSpec() + + await driver.manage().addCookie(cookie1) + await driver.manage().addCookie(cookie2) + + const storage = await Storage(driver) + + await storage.deleteCookies(new CookieFilter()) + + const result = await storage.getCookies() + + assert.strictEqual(result.cookies.length, 0) + }) + + xit('can delete cookie by name', async function () { + const cookie1 = createCookieSpec() + const cookie2 = createCookieSpec() + + const storage = await Storage(driver) + + await driver.manage().addCookie(cookie1) + await driver.manage().addCookie(cookie2) + + const result1 = await storage.getCookies(new CookieFilter()) + assert.strictEqual(result1.cookies.length, 2) + + await storage.deleteCookies(new CookieFilter().name(cookie1.name)) + + const result = await storage.getCookies(new CookieFilter()) + + assert.strictEqual(result.cookies.length, 1) + }) + + function createCookieSpec(opt_options) { + let spec = { + name: getRandomString(), + value: getRandomString(), + } + if (opt_options) { + spec = Object.assign(spec, opt_options) + } + return spec + } + + function assertHasCookies(...expected) { + return driver + .manage() + .getCookies() + .then(function (cookies) { + assert.strictEqual( + cookies.length, + expected.length, + 'Wrong # of cookies.' + + '\n Expected: ' + + JSON.stringify(expected) + + '\n Was : ' + + JSON.stringify(cookies), + ) + + const map = buildCookieMap(cookies) + for (let i = 0; i < expected.length; ++i) { + assert.strictEqual(expected[i].value, map[expected[i].name].value) + } + }) + } + + function buildCookieMap(cookies) { + const map = {} + cookies.forEach(function (cookie) { + map[cookie.name] = cookie + }) + return map + } + + function getRandomString() { + const x = 1234567890 + return Math.floor(Math.random() * x).toString(36) + } + }) + }, + { browsers: [Browser.FIREFOX] }, +)