diff --git a/.github/labeler.yml b/.github/labeler.yml index 4f1afd496..91fe99079 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -147,3 +147,8 @@ labels: sync: true matcher: files: ['packages/universal-xml-plugin/**'] + + - label: '@appium/strongbox' + sync: true + matcher: + files: ['packages/strongbox/**'] diff --git a/package-lock.json b/package-lock.json index eb3006c17..7d7ea13a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -139,6 +139,10 @@ "resolved": "packages/schema", "link": true }, + "node_modules/@appium/strongbox": { + "resolved": "packages/strongbox", + "link": true + }, "node_modules/@appium/support": { "resolved": "packages/support", "link": true @@ -8018,8 +8022,8 @@ }, "node_modules/env-paths": { "version": "2.2.1", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "engines": { "node": ">=6" } @@ -17598,6 +17602,14 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "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/smart-buffer": { "version": "4.2.0", "dev": true, @@ -20264,6 +20276,16 @@ "npm": ">=8" } }, + "packages/clerk": { + "name": "@appium/clerk", + "version": "0.1.0", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "env-paths": "2.2.1", + "slugify": "1.6.6" + } + }, "packages/doctor": { "name": "@appium/doctor", "version": "2.0.11", @@ -20623,6 +20645,14 @@ "npm": ">=8" } }, + "packages/strongbox": { + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "env-paths": "2.2.1", + "slugify": "1.6.6" + } + }, "packages/support": { "name": "@appium/support", "version": "3.1.9", diff --git a/packages/strongbox/README.md b/packages/strongbox/README.md new file mode 100644 index 000000000..6e08987f4 --- /dev/null +++ b/packages/strongbox/README.md @@ -0,0 +1,96 @@ +# @appium/strongbox + +> Persistent storage for Appium extensions + +## Summary + +This package is intended to be used in [Appium](https://appium.io) extensions which need to persist data between Appium runs. An example of such data may be a device token or key. + +`@appium/strongbox` provides a simple and extensible API for managing such data, while abstracting the underlying storage mechanism. + +_Note:_ This module is not intended for storing sensitive data. + +## Usage + +First, create an instance of `Strongbox`: + +```ts +import {strongbox} from '@appium/strongbox'; + +const box = strongbox('my-pkg'); +``` + +This instance corresponds to a unique collection of data. + +From here, create a placeholder for data (you will need to provide the type of data you intend to store): + +```ts +const item = await box.createItem('my unique name'); +``` + +...or, if you already have the data on-hand: + +```ts +const item: Buffer|string = getSomeData(); + +const item = await box.createItemWithContents('my unique name', data); +``` + +Either way, you can read its contents: + +```ts +// if the item doesn't exist, this result will be undefined +const contents = await item.read(); +``` + +Or write new data to the item: + +```ts +await item.write('new stuff'); +``` + +The last-read contents of the `Item` will be available on the `contents` property, but the value of this property is only current as of the last `read()`: + +```ts +const {contents} = item; +``` + +## API + +In lieu of actual documentation, look at the type definitions that this package ships. + +## Customization + +1. Create a class that implements the `Item` interface: + + ```ts + import {strongbox, Item} from '@appium/strongbox'; + import {Foo, getFoo} from 'somewhere/else'; + + class FooItem implements Item { + // ... + } + ``` + +2. Provide this class as the `defaultCtor` option to `strongbox()`: + + ```ts + const box = strongbox('my-pkg', {defaultCtor: FooItem}); + ``` + +3. Use like you would any other `Strongbox` instance: + + ```ts + const foo: Foo = getFoo(); + const item = await box.createItemWithValue('my unique name', Foo); + ``` + +## Default Behavior, For the Curious + +Out-of-the-box, a `Strongbox` instance corresponds to a directory on-disk, and each `Item` (returned by `createItem()/createItemWithContents()`) corresponds to a file within that directory. + +The directory of the `Strongbox` instance is determined by the [env-paths](https://www.npmjs.com/package/env-paths) package, and is platform-specific. + +## License + +Copyright © 2023 OpenJS Foundation. Licensed Apache-2.0 diff --git a/packages/strongbox/lib/base-item.ts b/packages/strongbox/lib/base-item.ts new file mode 100644 index 000000000..421c3098f --- /dev/null +++ b/packages/strongbox/lib/base-item.ts @@ -0,0 +1,95 @@ +import {mkdir, readFile, unlink, writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import type {Item, ItemEncoding, Value} from '.'; +import {slugify} from './util'; + +/** + * Base item implementation + * + * @remarks This class is not intended to be instantiated directly + * @typeParam T - Type of data stored in the `Item` + */ +export class BaseItem implements Item { + /** + * {@inheritdoc Item.value} + */ + protected _value?: T | undefined; + + /** + * Unique slugified identifier + */ + public readonly id: string; + + /** + * {@inheritdoc Item.value} + */ + public readonly value: T | undefined; + + /** + * Slugifies the name + * @param name Name of instance + * @param container Slugified name of container + * @param encoding Defaults to `utf8` + */ + constructor( + public readonly name: string, + public readonly container: string, + public readonly encoding: ItemEncoding = 'utf8' + ) { + this.id = path.join(container, slugify(name)); + + Object.defineProperties(this, { + value: { + get() { + return this._value; + }, + enumerable: true, + }, + _value: { + enumerable: false, + writable: true, + }, + }); + } + + /** + * {@inheritdoc Item.read} + */ + public async read(): Promise { + try { + this._value = (await readFile(this.id, { + encoding: this.encoding, + })) as T; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + } + return this._value; + } + + /** + * {@inheritdoc Item.write} + */ + public async write(value: T): Promise { + if (this._value !== value) { + await mkdir(path.dirname(this.id), {recursive: true}); + await writeFile(this.id, value, this.encoding); + this._value = value; + } + } + + /** + * {@inheritdoc Item.clear} + */ + public async clear(): Promise { + try { + await unlink(this.id); + this._value = undefined; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + } + } +} diff --git a/packages/strongbox/lib/index.ts b/packages/strongbox/lib/index.ts new file mode 100644 index 000000000..e7f4a3293 --- /dev/null +++ b/packages/strongbox/lib/index.ts @@ -0,0 +1,284 @@ +import envPaths from 'env-paths'; +import path from 'node:path'; +import {BaseItem} from './base-item'; +import {slugify, slugifyPath} from './util'; + +/** + * Valid file encodings. + * + * `null` means the file should be read and written as a `Buffer`. + */ +export type ItemEncoding = BufferEncoding | null; + +/** + * Valid value wrapped by {@linkcode Item}. Can be an encoded string or `Buffer` + */ +export type Value = string | Buffer; + +/** + * An object representing a persisted item containing something of type `T` (which can be a {@linkcode Buffer} or an encoded string; see {@linkcode ItemEncoding}). + * + * A {@linkcode Item} does not know anything about where it is stored, or how it is stored. + * @typeParam T - Type of data stored in the item + */ +export interface Item { + /** + * Encoding of underlying value + */ + encoding: ItemEncoding; + /** + * Slugified name + */ + id: string; + /** + * Name of item + */ + name: string; + + /** + * Reads value + */ + read(): Promise; + /** + * Writes value + * @param value New value + */ + write(value: T): Promise; + + /** + * Deletes the item. + */ + clear(): Promise; + + /** + * Last known value (stored in memory) + * + * @remarks A custom {@linkcode Item} meant to handle very large files should probably not implement this. + */ + value?: T | undefined; +} + +/** + * Set of known `Item` encodings + * @internal + */ +const ITEM_ENCODINGS: Readonly> = new Set([ + 'ascii', + 'utf8', + 'utf-8', + 'utf16le', + 'ucs2', + 'ucs-2', + 'base64', + 'base64url', + 'latin1', + 'binary', + 'hex', + null, +]); + +/** + * Type guard for encodings + * @param value any + * @returns `true` is `value` is a valid file encoding + */ +function isEncoding(value: any): value is ItemEncoding { + return ITEM_ENCODINGS.has(value); +} + +/** + * @see {@linkcode StrongboxOpts} + */ +export const DEFAULT_SUFFIX = 'strongbox'; + +/** + * A constructor function which instantiates a {@linkcode Item}. + */ +export type ItemCtor = new ( + name: string, + container: string, + encoding?: ItemEncoding +) => Item; + +/** + * Main entry point for use of this module + * + * Manages multiple {@linkcode Item}s. + */ +export class Strongbox { + /** + * Default {@linkcode ItemCtor} to use when creating new {@linkcode Item}s + */ + protected defaultItemCtor: ItemCtor; + /** + * Slugified name of this instance; 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 id: string; + + /** + * Override the directory of this container. + * + * If this is present, both `suffix` and `containerId` are unused. + */ + public readonly container: string; + + /** + * Store of known {@linkcode Item}s + * @internal + */ + protected items: Map>>; + + /** + * Slugifies the name & determines the directory + * @param name Name of instance + * @param opts Options + */ + protected constructor( + public readonly name: string, + {container, suffix = DEFAULT_SUFFIX, defaultItemCtor: defaultCtor = BaseItem}: StrongboxOpts = {} + ) { + this.id = slugify(name); + this.defaultItemCtor = defaultCtor; + this.items = new Map(); + this.container = container + ? slugifyPath(container) + : path.join(envPaths(this.id).data, slugify(suffix)); + } + + /** + * Creates a new {@linkcode Strongbox} + * @param name Name of instance + * @param opts Options + * @returns New instance + */ + public static create(name: string, opts?: StrongboxOpts) { + return new Strongbox(name, opts); + } + + /** + * Create a new {@linkcode Item}. + * + * Reads the item, if it is already persisted. Does not throw if missing. + * @param name Unique name of item + * @param encoding Encoding of item; defaults to `utf8` + * @returns New `Item` + * @typeParam T - Type of data stored in the `Item` + */ + async createItem( + name: string, + ctor?: ItemCtor, + encoding?: ItemEncoding + ): Promise>; + async createItem(name: string, encoding?: ItemEncoding): Promise>; + async createItem( + name: string, + encodingOrCtor?: ItemEncoding | ItemCtor, + encoding?: ItemEncoding + ): Promise> { + if (isEncoding(encodingOrCtor)) { + encoding = encodingOrCtor; + encodingOrCtor = this.defaultItemCtor; + } + const item = new (encodingOrCtor ?? (this.defaultItemCtor as ItemCtor))( + name, + this.container, + encoding + ); + if (this.items.has(item.id)) { + throw new ReferenceError(`Item with id "${item.id}" already exists`); + } + try { + await item.read(); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + } + this.items.set(item.id, new WeakRef(item)); + return item; + } + + /** + * Creates a {@linkcode Item} then immediately writes value to it. + * + * If it exists already, it will be overwritten. + * @param name Name of `Item` + * @param value File value to write + * @param ctor Specific {@linkcode ItemCtor} to use + * @param encoding File encoding + * @returns New `Item` w/ value of `value` + */ + async createItemWithValue( + name: string, + value: T, + ctor: ItemCtor, + encoding?: ItemEncoding + ): Promise>; + async createItemWithValue( + name: string, + value: T, + encoding?: ItemEncoding + ): Promise>; + async createItemWithValue( + name: string, + value: T, + encodingOrCtor?: ItemEncoding | ItemCtor, + encoding?: ItemEncoding + ): Promise> { + const item = isEncoding(encodingOrCtor) + ? await this.createItem(name, encodingOrCtor) + : await this.createItem(name, encodingOrCtor, encoding); + await item.write(value); + return item; + } + + /** + * Attempts to retrieve an {@linkcode Item} by its `id`. + * @param id ID of item + * @returns An `Item`, if found + */ + public getItem(id: string): Item | undefined { + const ref = this.items.get(id); + return ref?.deref(); + } + + /** + * Clears _all_ items, as well as the container. + */ + public async clearAll(): Promise { + const items = [...this.items.values()].map((ref) => ref.deref()).filter(Boolean) as Item[]; + await Promise.all(items.map((item) => item.clear())); + } +} + +/** + * Options for {@linkcode strongbox} + */ +export interface StrongboxOpts { + /** + * Default {@linkcode Item} constructor. + * + * Unless a constructor is specified when calling {@linkcode Strongbox.createItem} or {@linkcode Strongbox.createItemWithValue}, this will be used. + * @defaultValue BaseItem + */ + defaultItemCtor?: ItemCtor; + /** + * Override default container, which is chosen according to environment + */ + container?: string; + /** + * Extra subdir to append to the auto-generated file directory hierarchy. + * + * This is ignored if `container` is provided. + * @defaultValue 'strongbox' + */ + suffix?: string; +} + +/** + * {@inheritdoc Strongbox.create} + */ +export const strongbox = Strongbox.create; diff --git a/packages/strongbox/lib/util.ts b/packages/strongbox/lib/util.ts new file mode 100644 index 000000000..d85cf8dad --- /dev/null +++ b/packages/strongbox/lib/util.ts @@ -0,0 +1,10 @@ +import path from 'node:path'; +import slug from 'slugify'; + +export function slugify(value: string) { + return slug(value, {lower: true}); +} + +export function slugifyPath(filepath: string) { + return filepath.split(path.sep).map(slugify).join(path.sep); +} diff --git a/packages/strongbox/package.json b/packages/strongbox/package.json new file mode 100644 index 000000000..d51f05e2e --- /dev/null +++ b/packages/strongbox/package.json @@ -0,0 +1,52 @@ +{ + "name": "@appium/strongbox", + "version": "0.1.0", + "description": "Persistent storage for Appium extensions", + "keywords": [ + "automation", + "javascript", + "selenium", + "webdriver", + "ios", + "android", + "firefoxos", + "testing" + ], + "homepage": "https://appium.io", + "bugs": { + "url": "https://github.com/appium/appium/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/appium/appium.git", + "directory": "packages/strongbox" + }, + "license": "Apache-2.0", + "author": "Appium Contributors", + "main": "./build/lib/index.js", + "types": "./build/lib/index.d.ts", + "directories": { + "lib": "lib" + }, + "files": [ + "build/lib", + "lib", + "tsconfig.json" + ], + "scripts": { + "test": "npm run test:unit", + "test:smoke": "node .", + "test:unit": "mocha \"test/unit/**/*.spec.ts\"" + }, + "dependencies": { + "env-paths": "2.2.1", + "slugify": "1.6.6" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=8" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/strongbox/test/unit/strongbox.spec.ts b/packages/strongbox/test/unit/strongbox.spec.ts new file mode 100644 index 000000000..70c04776d --- /dev/null +++ b/packages/strongbox/test/unit/strongbox.spec.ts @@ -0,0 +1,156 @@ +import path from 'node:path'; +import rewiremock from 'rewiremock/node'; +import type {Strongbox as TStrongbox, StrongboxOpts} from '../../lib'; +import {createSandbox, SinonSandbox, SinonStubbedMember} from 'sinon'; +import type fs from 'node:fs/promises'; + +const {expect} = chai; +type MockFs = { + [K in keyof typeof fs]: SinonStubbedMember<(typeof fs)[K]>; +}; + +describe('Strongbox', function () { + let strongbox: (name: string, opts?: StrongboxOpts) => TStrongbox; + let Strongbox: new (name: string, opts?: StrongboxOpts) => TStrongbox; + let sandbox: SinonSandbox; + let DEFAULT_SUFFIX: string; + let MockFs: MockFs = {} as any; + + const DATA_DIR = '/some/dir'; + + beforeEach(function () { + sandbox = createSandbox(); + ({strongbox, DEFAULT_SUFFIX, Strongbox} = rewiremock.proxy(() => require('../../lib'), (r) => ({ + // all of these props are async functions + 'node:fs/promises': r + .mockThrough((prop) => { + MockFs = {...MockFs, [prop]: sandbox.stub().resolves()}; + return MockFs[prop as keyof typeof fs]; + }) + .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 Strongbox', function () { + const box = strongbox('test'); + expect(box).to.be.an.instanceOf(Strongbox); + }); + }); + }); + + describe('instance method', function () { + let box: TStrongbox; + + beforeEach(function () { + box = strongbox('test'); + }); + + describe('createItem()', function () { + describe('when a Item with the same id does not exist', function () { + describe('when the file does not exist', function () { + it('should create an empty Item', async function () { + const item = await box.createItem('SLUG test'); + expect(item).to.eql({ + id: '/some/dir/strongbox/slug-test', + name: 'SLUG test', + encoding: 'utf8', + value: undefined, + container: '/some/dir/strongbox', + }); + }); + }); + + describe('when the file exists', function () { + beforeEach(function () { + MockFs.readFile.resolves('foo bar'); + }); + it('should read its value', async function () { + const item = await box.createItem('SLUG test'); + expect(item).to.eql({ + id: '/some/dir/strongbox/slug-test', + name: 'SLUG test', + encoding: 'utf8', + value: 'foo bar', + container: '/some/dir/strongbox', + }); + }); + }); + + describe('when a value is written to the Item', function () { + it('should write a string value to the underlying file', async function () { + const item = await box.createItem('test'); + await item.write('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 item = await box.createItem('test'); + await item.write('boo bah'); + expect(item.value).to.equal('boo bah'); + }); + }); + }); + + describe('when a Item with the same id already exists', function () { + it('should throw an error', async function () { + await box.createItem('test'); + await expect(box.createItem('test')).to.be.rejectedWith( + Error, + 'Item with id "/some/dir/strongbox/test" already exists' + ); + }); + }); + }); + + describe('clearAll()', function () { + let clear: sinon.SinonStub>; + + beforeEach(async function () { + const item = await box.createItem('SLUG test'); + clear = sandbox.stub(item, 'clear'); + }); + + it('should call clear() on each item', async function () { + await box.clearAll(); + expect(clear).to.have.been.calledOnce; + }); + + describe('when there is some other error', function () { + beforeEach(function () { + clear.rejects(new Error('ETOOMANYGOATS')); + }); + + it('should reject', async function () { + await expect(box.clearAll()).to.be.rejected; + }); + }); + }); + + describe('createItemWithValue()', function () { + it('should create a Item with the given value', async function () { + const item = await box.createItemWithValue('test', 'value'); + expect(item.value).to.equal('value'); + }); + + it('should write the value to disk', async function () { + await box.createItemWithValue('test', 'value'); + expect(MockFs.writeFile).to.have.been.calledWith( + path.join(DATA_DIR, DEFAULT_SUFFIX, 'test'), + 'value' + ); + }); + }); + }); + + afterEach(function () { + sandbox.restore(); + }); +}); diff --git a/packages/strongbox/tsconfig.json b/packages/strongbox/tsconfig.json new file mode 100644 index 000000000..044c11f7a --- /dev/null +++ b/packages/strongbox/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@appium/tsconfig/tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "build", + "strict": true, + "types": ["node", "mocha", "chai", "chai-as-promised", "sinon-chai"] + }, + "include": ["lib", "test"] +} diff --git a/packages/tsconfig/tsconfig.json b/packages/tsconfig/tsconfig.json index c08a41bd6..a0755b38a 100644 --- a/packages/tsconfig/tsconfig.json +++ b/packages/tsconfig/tsconfig.json @@ -16,6 +16,7 @@ "sourceMap": true, "removeComments": false, "strict": false, - "types": ["node"] + "types": ["node"], + "lib": ["es2020", "ES2021.WeakRef"] } } diff --git a/tsconfig.json b/tsconfig.json index 0f6e84ad9..0f6fe89e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -67,6 +67,9 @@ }, { "path": "packages/doctor" + }, + { + "path": "packages/strongbox" } ] } diff --git a/typedoc.json b/typedoc.json index 9f6a26337..714522960 100644 --- a/typedoc.json +++ b/typedoc.json @@ -18,7 +18,8 @@ "./packages/driver-test-support", "./packages/test-support", "./packages/plugin-test-support", - "./packages/opencv" + "./packages/opencv", + "./packages/strongbox" ], "name": "Appium", "out": "./packages/appium/docs/en/reference",