feat(strongbox): create @appium/strongbox

This PR creates a new package `@appium/strongbox`, which provides a generic persistence store for Appium extensions.
This commit is contained in:
Christopher Hiller
2023-04-06 16:03:12 -07:00
parent 1d5070e891
commit fd912346fa
12 changed files with 747 additions and 4 deletions

5
.github/labeler.yml vendored
View File

@@ -147,3 +147,8 @@ labels:
sync: true
matcher:
files: ['packages/universal-xml-plugin/**']
- label: '@appium/strongbox'
sync: true
matcher:
files: ['packages/strongbox/**']

34
package-lock.json generated
View File

@@ -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",

View File

@@ -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<string>('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<Foo> {
// ...
}
```
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

View File

@@ -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<T extends Value> implements Item<T> {
/**
* {@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<T | undefined> {
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<void> {
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<void> {
try {
await unlink(this.id);
this._value = undefined;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
throw e;
}
}
}
}

View File

@@ -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<T extends Value> {
/**
* Encoding of underlying value
*/
encoding: ItemEncoding;
/**
* Slugified name
*/
id: string;
/**
* Name of item
*/
name: string;
/**
* Reads value
*/
read(): Promise<T | undefined>;
/**
* Writes value
* @param value New value
*/
write(value: T): Promise<void>;
/**
* Deletes the item.
*/
clear(): Promise<void>;
/**
* 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<Set<ItemEncoding>> = 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<T extends Value> = new (
name: string,
container: string,
encoding?: ItemEncoding
) => Item<T>;
/**
* 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<any>;
/**
* 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<string, WeakRef<Item<any>>>;
/**
* 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<T extends Value>(
name: string,
ctor?: ItemCtor<T>,
encoding?: ItemEncoding
): Promise<Item<T>>;
async createItem<T extends Value>(name: string, encoding?: ItemEncoding): Promise<Item<T>>;
async createItem<T extends Value>(
name: string,
encodingOrCtor?: ItemEncoding | ItemCtor<T>,
encoding?: ItemEncoding
): Promise<Item<T>> {
if (isEncoding(encodingOrCtor)) {
encoding = encodingOrCtor;
encodingOrCtor = this.defaultItemCtor;
}
const item = new (encodingOrCtor ?? (this.defaultItemCtor as ItemCtor<T>))(
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<T extends Value>(
name: string,
value: T,
ctor: ItemCtor<T>,
encoding?: ItemEncoding
): Promise<Item<T>>;
async createItemWithValue<T extends Value>(
name: string,
value: T,
encoding?: ItemEncoding
): Promise<Item<T>>;
async createItemWithValue<T extends Value>(
name: string,
value: T,
encodingOrCtor?: ItemEncoding | ItemCtor<T>,
encoding?: ItemEncoding
): Promise<Item<T>> {
const item = isEncoding(encodingOrCtor)
? await this.createItem<T>(name, encodingOrCtor)
: await this.createItem<T>(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<any> | undefined {
const ref = this.items.get(id);
return ref?.deref();
}
/**
* Clears _all_ items, as well as the container.
*/
public async clearAll(): Promise<void> {
const items = [...this.items.values()].map((ref) => ref.deref()).filter(Boolean) as Item<any>[];
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<any>;
/**
* 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;

View File

@@ -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);
}

View File

@@ -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"
}
}

View File

@@ -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<never[], Promise<void>>;
beforeEach(async function () {
const item = await box.createItem<string>('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();
});
});

View File

@@ -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"]
}

View File

@@ -16,6 +16,7 @@
"sourceMap": true,
"removeComments": false,
"strict": false,
"types": ["node"]
"types": ["node"],
"lib": ["es2020", "ES2021.WeakRef"]
}
}

View File

@@ -67,6 +67,9 @@
},
{
"path": "packages/doctor"
},
{
"path": "packages/strongbox"
}
]
}

View File

@@ -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",