diff --git a/lib/driver.js b/lib/driver.js index 39bf8790f..67bbf34de 100644 --- a/lib/driver.js +++ b/lib/driver.js @@ -18,8 +18,7 @@ import {KEYMAP} from './keys'; import log from './logger'; import {LGRemoteKeys} from './remote/lg-remote-client'; import {LGWSClient} from './remote/lg-socket-client'; -// eslint-disable-next-line import/no-unresolved -import {ValueBox} from './remote/valuebox'; +import {strongbox} from '@appium/strongbox'; export {KEYS} from './keys'; // this is the ID for the 'Developer' application, which we launch after a session ends to ensure @@ -141,9 +140,9 @@ export class WebOSDriver extends BaseDriver { await installApp(app, appId, deviceName); } - this.valueBox = ValueBox.create('appium-lg-webos-driver'); + this.strongbox = strongbox('appium-lg-webos-driver'); this.socketClient = new LGWSClient({ - valueBox: this.valueBox, + strongbox: this.strongbox, deviceName, url: `ws://${deviceHost}:${websocketPort}`, urlSecure: `wss://${deviceHost}:${websocketPortSecure}`, diff --git a/lib/remote/lg-socket-client.js b/lib/remote/lg-socket-client.js index 4e51fce89..9040bf965 100644 --- a/lib/remote/lg-socket-client.js +++ b/lib/remote/lg-socket-client.js @@ -41,12 +41,12 @@ export class LGWSClient { #remoteKeyCooldown; /** - * @type {import('./valuebox').ValueWrapper} + * @type {import('@appium/strongbox').Item} */ #keystore; - /** @type {import('./valuebox').ValueBox} */ - #valueBox; + /** @type {import('@appium/strongbox').Strongbox} */ + #strongbox; /** * Unique identifier for key based on device name. @@ -66,13 +66,13 @@ export class LGWSClient { url, urlSecure, useSecureWebsocket, - valueBox, + strongbox, deviceName, log = logger.getLogger('LGWsClient'), saveClientKey = true, remoteKeyCooldown, }) { - this.#valueBox = valueBox; + this.#strongbox = strongbox; this.#url = url; this.#urlSecure = urlSecure; this.#useSecureWebsocket = useSecureWebsocket; @@ -189,11 +189,11 @@ export class LGWSClient { /** * Gets / initializes the keystore based on the keystore ID * @see {@linkcode #keystoreId} - * @returns {Promise>} + * @returns {Promise>} */ async #getKeystore() { return ( - this.#keystore ?? (this.#keystore = await this.#valueBox.createWrapper(this.#keystoreId)) + this.#keystore ?? (this.#keystore = await this.#strongbox.createItem(this.#keystoreId)) ); } @@ -206,7 +206,7 @@ export class LGWSClient { try { const keystore = await this.#getKeystore(); this.#log.info( - `Trying to read key from file on disk at ${path.join(this.#valueBox.dir, keystore.id)}` + `Trying to read key from file on disk at ${path.join(this.#strongbox.container, this.#keystoreId)}` ); this.#clientKey = keystore.value; this.#log.info(`Success: key is '${this.#clientKey}'`); @@ -229,7 +229,7 @@ export class LGWSClient { if (this.#saveClientKey && this.#clientKey !== undefined && this.#clientKey !== origClientKey) { this.#log.info(`Client key changed, writing it to disk`); const keystore = await this.#getKeystore(); - await keystore.put(this.#clientKey); + await keystore.write(this.#clientKey); } return /** @type {string} */ (this.#clientKey); } diff --git a/lib/remote/valuebox.ts b/lib/remote/valuebox.ts deleted file mode 100644 index ddef330f0..000000000 --- a/lib/remote/valuebox.ts +++ /dev/null @@ -1,327 +0,0 @@ -import envPaths from 'env-paths'; -import {readFile, writeFile, mkdir, unlink, rm} from 'node:fs/promises'; -import slugify from 'slugify'; -import path from 'node:path'; - -export type ValueEncoding = BufferEncoding | null; -export type Value = string | Buffer; -/** - * Represents a "value", which is just a file on disk containing something. - * - * A {@linkcode ValueWrapper} does not know anything about where it is stored, or how it is stored. - */ -export interface ValueWrapper { - /** - * Slugified name - */ - id: string; - /** - * Name of value - */ - name: string; - - /** - * Encoding of value - */ - encoding: ValueEncoding; - /** - * Read value from disk - */ - look(): Promise; - /** - * Write to value - * @param value New value to write to value - */ - put(value: V): Promise; - - /** - * Current value, if any - */ - get value(): V | undefined; -} - -/** - * A function which reads from a value - */ -export interface ValueLooker { - (value: ValueWrapper): Promise; -} -/** - * A function which writes to a value - */ -export interface ValuePutter { - (value: ValueWrapper, data: V): Promise; -} - -/** - * A value which stores its value in a file on disk - * - * This class is not intended to be instantiated directly - * - */ -export class BaseValueWrapper implements ValueWrapper { - /** - * Underlying value - */ - #value: V | undefined; - - /** - * Slugified name - */ - public readonly id: string; - /** - * Function which reads a value - */ - private readonly looker: ValueLooker; - /** - * Function which writes a value - */ - private readonly putter: ValuePutter; - - /** - * Underlying value - */ - public readonly value: V; - - /** - * Slugifies the name - * @param name Name of value - * @param looker Reader fn - * @param putter Writer fn - * @param encoding Defaults to `utf8` - */ - constructor( - public readonly name: string, - looker: ValueLooker, - putter: ValuePutter, - public readonly encoding: ValueEncoding = 'utf8' - ) { - this.id = slugify(name, {lower: true}); - - Object.defineProperties(this, { - looker: {value: looker}, - putter: {value: putter}, - value: { - get() { - return this.#value; - }, - enumerable: true, - }, - }); - } - - /** - * {@inheritdoc IValueWrapper.read} - */ - async look(): Promise { - this.#value = await this.looker(this); - return this.#value; - } - - /** - * {@inheritdoc IValueWrapper.write} - */ - async put(value: V): Promise { - await this.putter(this, value); - this.#value = value; - } -} - -/** - * @see {@linkcode ValueBoxOpts} - */ -export const DEFAULT_SUFFIX = 'valuebox'; - -/** - * A class which instantiates a {@linkcode ValueWrapper}. - */ -export interface ValueConstructor { - new ( - name: string, - reader: ValueLooker, - writer: ValuePutter, - encoding?: ValueEncoding - ): ValueWrapper; -} - -/** - * Main entry point for use of this module - * - * Manages multiple values. - */ -export class ValueBox { - /** - * Slugified name of this container; corresponds to the directory name. - * - * If `dir` is provided, this value is unused. - * If `suffix` is provided, then this will be the parent directory of `suffix`. - */ - public readonly containerId: string; - /** - * Override the directory of this container. - * - * If this is present, both `suffix` and `containerId` are unused. - */ - public readonly dir: string; - - protected ctor?: ValueConstructor; - - /** - * Factory function for creating new {@linkcode ValueWrapper}s} - */ - protected wrapperIds: Set = new Set(); - - protected constructor( - public readonly name: string, - {dir, suffix = DEFAULT_SUFFIX, defaultCtor: ctor = BaseValueWrapper}: ValueBoxOpts = {} - ) { - this.containerId = slugify(name, {lower: true}); - this.ctor = ctor; - this.dir = dir ?? path.join(envPaths(this.containerId).data, suffix); - } - - /** - * "mkdirp"'s the value directory and writes it to disk - * @param value ValueWrapper to write - * @param value Value to write to the value - */ - private async put(wrapper: ValueWrapper, value: string): Promise { - await this.init(); - await writeFile(path.join(this.dir, wrapper.id), value, wrapper.encoding); - } - - /** - * "mkdirp"'s the value directory - */ - private async init(): Promise { - await mkdir(this.dir, {recursive: true}); - } - - /** - * Removes _all_ values from disk by truncating the value directory. - * - * Convniently removes everything else in the value directory, even if it isn't a value! - */ - public async recycleAll() { - try { - await rm(this.dir, {recursive: true, force: true}); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { - throw e; - } - } - } - /** - * Reads a value in a value from disk - * @param value ValueWrapper to read - */ - protected async look(wrapper: ValueWrapper): Promise { - try { - return (await readFile(path.join(this.dir, wrapper.id), { - encoding: wrapper.encoding, - })) as V; - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { - throw e; - } - } - } - - /** - * Removes a value from disk. - * - * This does _not_ destroy the `ValueWrapper` instance in memory, nor does it allow the `id` to be reused. - * @param wrapper ValueWrapper to drop - */ - async recycle(wrapper: ValueWrapper) { - try { - await unlink(path.join(this.dir, wrapper.id)); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { - throw e; - } - } - } - - /** - * Create a new {@linkcode ValueWrapper}. Reads the value from disk, if present. - * @param name Name of value - * @param encoding Encoding of value; defaults to `utf8` - * @returns New value - */ - async createWrapper( - name: string, - encoding?: ValueEncoding - ): Promise> { - const wrapper = new (this.ctor as ValueConstructor)( - name, - this.look.bind(this), - this.put.bind(this), - encoding - ); - if (this.wrapperIds.has(wrapper.id)) { - throw new Error(`ValueWrapper with id "${wrapper.id}" already exists`); - } - try { - await wrapper.look(); - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { - throw e; - } - } - this.wrapperIds.add(wrapper.id); - return wrapper; - } - - /** - * Creates a {@linkcode ValueWrapper} then immediately writes a value to it. - * If there was anything on disk, it is overwritten. - * @param name Name of value - * @param value Value to write - * @returns New `ValueWrapper` w/ value of `value` - */ - async createWrapperWithValue( - name: string, - value: V, - encoding?: ValueEncoding - ): Promise> { - const wrapper = await this.createWrapper(name, encoding); - await wrapper.put(value); - return wrapper; - } - - /** - * Creates a new {@linkcode ValueBox} - * @param name Name of value container - * @param opts Options - * @returns New value - */ - static create(name: string, opts?: ValueBoxOpts) { - return new ValueBox(name, opts); - } -} - -export interface ValueBox { - /** - * Creates a new {@linkcode ValueBox} - * @param name Name of value container - * @param opts Options - * @returns New value - */ - create(name: string, opts?: ValueBoxOpts): ValueBox; -} - -export interface ValueBoxOpts { - /** - * Override default value directory, which is chosen according to environment - */ - dir?: string; - - /** - * Extra subdir to append to the auto-generated value directory. Ignored if `dir` is a `string`. - * @defaultValue 'valuebox' - */ - suffix?: string; - - defaultCtor?: ValueConstructor; -} diff --git a/lib/types.ts b/lib/types.ts index ad7d72109..f07ecde4f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -2,7 +2,7 @@ import {AppiumLogger} from '@appium/types'; import {JsonPrimitive, SetRequired, Writable} from 'type-fest'; import {KnownKey} from './keys'; import {AuthPayload, MessageType} from './remote/constants'; -import {ValueBox} from './remote/valuebox'; +import type {Strongbox} from '@appium/strongbox'; /** * Extra caps that cannot be inferred from constraints. @@ -76,7 +76,7 @@ export interface LGSocketClientOpts { url: string; urlSecure: string; useSecureWebsocket: boolean; - valueBox: ValueBox; + strongbox: Strongbox; clientKey?: string; log?: AppiumLogger; clientKeyFile?: string; diff --git a/package-lock.json b/package-lock.json index c642ac2fc..35c026584 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.1", "license": "Apache-2.0", "dependencies": { + "@appium/strongbox": "0.3.2", "@humanwhocodes/env": "^2.2.0", "@types/ws": "^8.5.3", "appium-chromedriver": "^5.1.1", @@ -55,7 +56,7 @@ "webdriverio": "7.25.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "node": ">=18.0.0", "npm": ">=8" }, "peerDependencies": { @@ -390,6 +391,27 @@ "npm": ">=8" } }, + "node_modules/@appium/strongbox": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@appium/strongbox/-/strongbox-0.3.2.tgz", + "integrity": "sha512-3UJi5MP+MBfDomaBtzuXtCzYJCSTpYKfM2bcFp4yl8AWpfSzZLgszOY31Gg8/u1R3LZ9irdBGYxb5wqyaQbYLw==", + "dependencies": { + "env-paths": "2.2.1", + "slugify": "1.6.6" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=8" + } + }, + "node_modules/@appium/strongbox/node_modules/slugify": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", + "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@appium/support": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/@appium/support/-/support-3.1.6.tgz", @@ -11376,6 +11398,22 @@ "source-map-support": "0.5.21" } }, + "@appium/strongbox": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@appium/strongbox/-/strongbox-0.3.2.tgz", + "integrity": "sha512-3UJi5MP+MBfDomaBtzuXtCzYJCSTpYKfM2bcFp4yl8AWpfSzZLgszOY31Gg8/u1R3LZ9irdBGYxb5wqyaQbYLw==", + "requires": { + "env-paths": "2.2.1", + "slugify": "1.6.6" + }, + "dependencies": { + "slugify": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", + "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==" + } + } + }, "@appium/support": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/@appium/support/-/support-3.1.6.tgz", diff --git a/package.json b/package.json index 6165f987b..827864e07 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "singleQuote": true }, "dependencies": { + "@appium/strongbox": "0.3.2", "@humanwhocodes/env": "^2.2.0", "@types/ws": "^8.5.3", "appium-chromedriver": "^5.1.1", diff --git a/test/unit/valuebox.spec.ts b/test/unit/valuebox.spec.ts deleted file mode 100644 index 67eff72ae..000000000 --- a/test/unit/valuebox.spec.ts +++ /dev/null @@ -1,197 +0,0 @@ -import path from 'node:path'; -import rewiremock from 'rewiremock/node'; -import type {ValueBox, ValueWrapper} from '../../lib/remote/valuebox'; -import {createSandbox, SinonSandbox, SinonStubbedMember} from 'sinon'; -import type fs from 'node:fs/promises'; - -const {expect} = chai; -type MockFs = { - [K in keyof typeof fs]: SinonStubbedMember; -}; - -describe('ValueBox', function () { - let ValueBox: ValueBox; - let sandbox: SinonSandbox; - let DEFAULT_SUFFIX: string; - let MockFs: MockFs = {} as any; - - const DATA_DIR = '/some/dir'; - - beforeEach(function () { - sandbox = createSandbox(); - ({ValueBox, DEFAULT_SUFFIX} = rewiremock.proxy( - () => require('../../lib/remote/valuebox'), - (r) => ({ - // all of these props are async functions - 'node:fs/promises': r - .mockThrough((prop) => { - MockFs[prop] = sandbox.stub().resolves(); - return MockFs[prop]; - }) - .dynamic(), // this allows us to change the mock behavior on-the-fly - 'env-paths': sandbox.stub().returns({data: DATA_DIR}), - }) - )); - }); - - describe('static method', function () { - describe('create()', function () { - it('should return a new ValueBox', function () { - const valueBox = ValueBox.create('test'); - expect(valueBox).to.be.an.instanceOf(ValueBox); - }); - }); - }); - - describe('instance method', function () { - let valueBox: ValueBox; - - beforeEach(function () { - valueBox = ValueBox.create('test'); - }); - - describe('createWrapper()', function () { - describe('when a ValueWrapper with the same id does not exist', function () { - describe('when the file does not exist', function () { - it('should create an empty ValueWrapper', async function () { - const wrapper = await valueBox.createWrapper('SLUG test'); - expect(wrapper).to.eql({ - id: 'slug-test', - name: 'SLUG test', - encoding: 'utf8', - value: undefined, - }); - }); - }); - - describe('when the file exists', function () { - beforeEach(function () { - MockFs.readFile.resolves('foo bar'); - }); - it('should read its value', async function () { - const wrapper = await valueBox.createWrapper('SLUG test'); - expect(wrapper).to.eql({ - id: 'slug-test', - name: 'SLUG test', - encoding: 'utf8', - value: 'foo bar', - }); - }); - }); - - describe('when a value is written to the ValueWrapper', function () { - it('should write a string value to the underlying file', async function () { - const wrapper = await valueBox.createWrapper('test'); - await wrapper.put('boo bah'); - - expect(MockFs.writeFile).to.have.been.calledWith( - path.join(DATA_DIR, DEFAULT_SUFFIX, 'test'), - 'boo bah', - 'utf8' - ); - }); - - it('should update the underlying value', async function () { - const wrapper = await valueBox.createWrapper('test'); - await wrapper.put('boo bah'); - expect(wrapper.value).to.equal('boo bah'); - }); - }); - }); - - describe('when a ValueWrapper with the same id already exists', function () { - it('should throw an error', async function () { - await valueBox.createWrapper('test'); - await expect(valueBox.createWrapper('test')).to.be.rejectedWith( - Error, - 'ValueWrapper with id "test" already exists' - ); - }); - }); - }); - - describe('recycle()', function () { - it('should attempt to unlink the underlying file', async function () { - const wrapper = await valueBox.createWrapper('test'); - await valueBox.recycle(wrapper); - expect(MockFs.unlink).to.have.been.calledWith(path.join(DATA_DIR, DEFAULT_SUFFIX, 'test')); - }); - - describe('when the underlying file does not exist', function () { - beforeEach(function () { - MockFs.unlink.rejects(Object.assign(new Error(), {code: 'ENOENT'})); - }); - - it('should not reject', async function () { - const wrapper = await valueBox.createWrapper('test'); - await expect(valueBox.recycle(wrapper)).to.eventually.be.undefined; - }); - - it('should call unlink with the correct path', async function () { - await valueBox.recycle(await valueBox.createWrapper('test')); - expect(MockFs.unlink).to.have.been.calledOnceWith('/some/dir/valuebox/test'); - }); - }); - - describe('when there is some other error', function () { - beforeEach(function () { - MockFs.unlink.rejects(Object.assign(new Error(), {code: 'ETOOMANYGOATS'})); - }); - - it('should reject', async function () { - const wrapper = await valueBox.createWrapper('test'); - await expect(valueBox.recycle(wrapper)).to.be.rejected; - }); - }); - }); - - describe('recycleAll()', function () { - describe('when the underlying dir does not exist', function () { - beforeEach(function () { - MockFs.rm.rejects(Object.assign(new Error(), {code: 'ENOENT'})); - }); - - it('should not reject', async function () { - await expect(valueBox.recycleAll()).to.eventually.be.undefined; - }); - - it('should call rm with the correct path', async function () { - await valueBox.recycleAll(); - expect(MockFs.rm).to.have.been.calledOnceWith('/some/dir/valuebox', { - recursive: true, - force: true, - }); - }); - }); - - describe('when there is some other error', function () { - beforeEach(function () { - MockFs.rm.rejects(Object.assign(new Error(), {code: 'ETOOMANYGOATS'})); - }); - - it('should reject', async function () { - await expect(valueBox.recycleAll()).to.be.rejected; - }); - }); - }); - - describe('createWrapperWithValue()', function () { - it('should create a ValueWrapper with the given value', async function () { - const wrapper = await valueBox.createWrapperWithValue('test', 'value'); - expect(wrapper.value).to.equal('value'); - }); - - it('should write the value to disk', async function () { - await valueBox.createWrapperWithValue('test', 'value'); - expect(MockFs.writeFile).to.have.been.calledWith( - path.join(DATA_DIR, DEFAULT_SUFFIX, 'test'), - 'value' - ); - }); - }); - }); - - afterEach(function () { - sandbox.restore(); - }); -});