test(coverage): 100% test coverage & refactor to vitest and msw (#6)

* chore(refactor): tests use vitest and msw
* fix(directory): Ensure filenames conform to eslint requirements
* chore(yarn): Specify node engine version
* fix(yarn): use the proper nodelinker
* fix(GitHub workflow): yarn install with caching
* docs(README): update to match changes

---------

Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com>
This commit is contained in:
Iain Sproat
2023-03-23 15:37:32 +00:00
committed by GitHub
parent 1f8e68e47b
commit 1fceda4724
42 changed files with 35250 additions and 20001 deletions
+10
View File
@@ -11,6 +11,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.4.0
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Yarn Install
run: yarn install
- uses: actions/cache/save@v3
id: cache-save
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- run: |
yarn run run:mockserver & # run mockserver in background, will be killed when test finishes
- uses: ./
+3
View File
@@ -39,3 +39,6 @@ node_modules
lib
coverage/
# asdf config file
.tool-versions
Generated
-17141
View File
File diff suppressed because one or more lines are too long
-2042
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Test with YARN",
"request": "launch",
"console": "integratedTerminal",
"runtimeArgs": ["test"],
"runtimeExecutable": "yarn",
"skipFiles": ["<node_internals>/**"],
"type": "node"
// "envFile": "${workspaceFolder}/.env"
}
]
}
-5
View File
@@ -1,5 +0,0 @@
{
"yaml.schemas": {
"kubernetes://schema/speckle.systems/v1alpha1@specklefunction": "file:///Users/iainsproat/workspace/speckle-automate-github-action/examples/minimal/specklefunction.yaml"
}
}
+2
View File
@@ -1 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-3.5.0.cjs
+95 -35
View File
@@ -4,7 +4,7 @@
## Introduction
This repository contains the source code for the Speckle Automate GitHub Action. It is a GitHub Action that builds a Speckle Automate Function from your source code.
This repository contains the source code for the Speckle Automate GitHub Action. It is a GitHub Action that publishes a Speckle Automate Function to Speckle Automate your source code.
## Documentation
@@ -26,27 +26,48 @@ If you believe your token has been compromised, please revoke it immediately on
Please note that this is not a Speckle Account token, but a **Speckle Automate API** token. You can create one by logging into the [Speckle Automate Server](https://automate.speckle.xyz) and going to the [API Tokens](https://automate.speckle.xyz/tokens) page.
`speckle_function_path`
The path to the Speckle Automate Function to publish. This path is relative to the root of the repository. If you provide a path to a directory, your Speckle Automate Function must be in a file named `specklefunction.yaml` within that directory.
`function_id`
*Optional.* If you have already registered a Speckle Function, you can use the ID of that Speckle Function to ensure that any changes are associated with it.
If you do not provide a Function Id, we will attempt to determine the Function ID based on the GitHub server, GitHub repository, Reference (branch), and the Speckle Function Path.
Providing a Speckle Function ID allows you to change one of those values, and update the original Function instead of creating a new one.
Your Speckle Token must have write permissions for the Speckle Function with this ID, otherwise the publish will fail.
### Outputs
#### `function_id`
The unique ID of the published function.
The unique ID of the published function. This will be the same as the `function_id` input if it was provided.
#### `version_id`
The unique ID of this version of the published function.
#### `image_name`
The name of the Docker image that you now need to build and push. Your Speckle Automate Token has been granted write permissions for publishing this image to the Speckle Automate Server.
### Example usage
Speckle Automate GitHub Action will register a Speckle Function with Speckle Automate. This is a necessary, but not sufficient, step in publishing your Speckle Function. You must also build and push the Docker image that contains your Speckle Function.
#### Publish a function to automate.speckle.xyz
```yaml
uses: actions/speckle-automate-github-action@0.1.0
with:
# speckle_server_url is optional and defaults to https://automate.speckle.xyz
# speckle_server_url: https://automate.speckle.xyz
# The speckle_token is a secret and must be stored in GitHub as an encrypted secret
# https://docs.github.com/en/actions/security-guides/encrypted-secrets#using-encrypted-secrets-in-a-workflow
speckle_token: ${{ secrets.speckle_token }}
speckle_token: ${{ secrets.SPECKLE_TOKEN }}
# speckle_function_path is optional and defaults to ./specklefunction.yaml
# function_id is optional and will be auto-generated if not provided
```
#### Publish a function to a self-hosted server
@@ -57,7 +78,70 @@ with:
# please update to the url of your self-hosted server
speckle_server_url: https://example.org
# https://docs.github.com/en/actions/security-guides/encrypted-secrets#using-encrypted-secrets-in-a-workflow
speckle_token: ${{ secrets.speckle_token }}
speckle_token: ${{ secrets.SPECKLE_TOKEN }}
# speckle_function_path is optional and defaults to ./specklefunction.yaml
# function_id is optional and will be auto-generated if not provided
```
### Example usage within an entire GitHub Actions Workflow
#### Publish a Speckle Function, and build the Docker Image using Docker
Docker is the original, and still one of the most popular, ways to build and publish Docker images. It does require that you provide a [Dockerfile](https://docs.docker.com/engine/reference/builder/), which includes instructions to Docker for building your image.
Find out more about Docker and Dockerfiles by following Docker's [Get Started Guide](https://docs.docker.com/get-started/).
You can learn more about Docker's GitHub Action from their documentation in the [GitHub Actions Marketplace](https://github.com/marketplace/actions/build-and-push-docker-images).
```yaml
name: ci
on:
push:
branches:
- 'main'
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
# Checkout the code
# Docker's GitHub Action does not require this step
# but Speckle Automate does
name: Checkout
uses: actions/checkout@v3
-
id: speckle
name: Register Speckle Function
uses: actions/speckle-automate-github-action@0.1.0
with:
speckle_token: ${{ secrets.SPECKLE_TOKEN }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Docker needs to login to Speckle Automate
uses: docker/login-action@v2
with:
username: ${{ secrets.SPECKLE_USERNAME }}
password: ${{ secrets.SPECKLE_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v4
with:
# ## file is optional and defaults to {context}/Dockerfile
# file: ./Dockerfile
# ## context is optional and defaults to the root directory '.' of the repository
# context: .
# ## platforms is optional and defaults to linux/amd64
# ## platforms must match the platforms that you have registered with Speckle Automate, which also defaults to linux/amd64.
# platforms: linux/amd64
push: true
tags: ${{ steps.speckle.outputs.image_name }}
```
## Developing & Debugging
@@ -73,32 +157,8 @@ with:
#### Building
1. Clone this repository
1. Run `yarn install` to install dependencies
1. Run `yarn build`
#### Running built image
1. Set the Speckle Server URL and Speckle Token environment variables, and run the image:
```bash
export SPECKLE_SERVER_URL="https://automate.speckle.xyz" # (or your self-hosted server, which may be `localhost:3000` if running a development server)
export SPECKLE_TOKEN="ABCD1234" #(replace with your token)
docker run --rm \
-e SPECKLE_SERVER_URL="${SPECKLE_SERVER_URL}" \
-e SPECKLE_TOKEN="${SPECKLE_TOKEN}" \
-e SPECKLE_FUNCTION_PATH="./examples/basic" \
-e GITHUB_WORKSPACE="/home/runner/work/specklesystems/speckle-automate-github-action" \
-e GITHUB_OUTPUT="/output/github_output" \
-v "$(pwd):/home/runner/work/specklesystems/speckle-automate-github-action" \
-v "/tmp:/output" \
speckle/speckle-automate-github-action:local
```
1. Inspect the output at `/tmp/github_output`:
```bash
cat /tmp/github_output
```
1. Run `yarn install` to install dependencies.
1. Run `yarn run all` to validate, build, and test the project.
### Developing
@@ -115,22 +175,22 @@ with:
#### Testing
1. Run unit tests:
1. Run unit tests with coverage:
```bash
yarn test
```
1. Run integration tests:
1. Run unit tests and watch for changes while developing:
```bash
yarn test:e2e
yarn test:watch
```
#### Linting
1. Run `yarn pre-commit` to run all pre-commit hooks.
1. Address all linting errors prior to committing changes.
1. You must address all linting errors prior to committing changes. The CI will fail if there are any linting errors, and you will be unable to merge your PR.
## Contributing
Generated Vendored
+452
View File
@@ -0,0 +1,452 @@
export const id = 37;
export const ids = [37];
export const modules = {
/***/ 4037:
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "toFormData": () => (/* binding */ toFormData)
/* harmony export */ });
/* harmony import */ var fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2777);
/* harmony import */ var formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8010);
let s = 0;
const S = {
START_BOUNDARY: s++,
HEADER_FIELD_START: s++,
HEADER_FIELD: s++,
HEADER_VALUE_START: s++,
HEADER_VALUE: s++,
HEADER_VALUE_ALMOST_DONE: s++,
HEADERS_ALMOST_DONE: s++,
PART_DATA_START: s++,
PART_DATA: s++,
END: s++
};
let f = 1;
const F = {
PART_BOUNDARY: f,
LAST_BOUNDARY: f *= 2
};
const LF = 10;
const CR = 13;
const SPACE = 32;
const HYPHEN = 45;
const COLON = 58;
const A = 97;
const Z = 122;
const lower = c => c | 0x20;
const noop = () => {};
class MultipartParser {
/**
* @param {string} boundary
*/
constructor(boundary) {
this.index = 0;
this.flags = 0;
this.onHeaderEnd = noop;
this.onHeaderField = noop;
this.onHeadersEnd = noop;
this.onHeaderValue = noop;
this.onPartBegin = noop;
this.onPartData = noop;
this.onPartEnd = noop;
this.boundaryChars = {};
boundary = '\r\n--' + boundary;
const ui8a = new Uint8Array(boundary.length);
for (let i = 0; i < boundary.length; i++) {
ui8a[i] = boundary.charCodeAt(i);
this.boundaryChars[ui8a[i]] = true;
}
this.boundary = ui8a;
this.lookbehind = new Uint8Array(this.boundary.length + 8);
this.state = S.START_BOUNDARY;
}
/**
* @param {Uint8Array} data
*/
write(data) {
let i = 0;
const length_ = data.length;
let previousIndex = this.index;
let {lookbehind, boundary, boundaryChars, index, state, flags} = this;
const boundaryLength = this.boundary.length;
const boundaryEnd = boundaryLength - 1;
const bufferLength = data.length;
let c;
let cl;
const mark = name => {
this[name + 'Mark'] = i;
};
const clear = name => {
delete this[name + 'Mark'];
};
const callback = (callbackSymbol, start, end, ui8a) => {
if (start === undefined || start !== end) {
this[callbackSymbol](ui8a && ui8a.subarray(start, end));
}
};
const dataCallback = (name, clear) => {
const markSymbol = name + 'Mark';
if (!(markSymbol in this)) {
return;
}
if (clear) {
callback(name, this[markSymbol], i, data);
delete this[markSymbol];
} else {
callback(name, this[markSymbol], data.length, data);
this[markSymbol] = 0;
}
};
for (i = 0; i < length_; i++) {
c = data[i];
switch (state) {
case S.START_BOUNDARY:
if (index === boundary.length - 2) {
if (c === HYPHEN) {
flags |= F.LAST_BOUNDARY;
} else if (c !== CR) {
return;
}
index++;
break;
} else if (index - 1 === boundary.length - 2) {
if (flags & F.LAST_BOUNDARY && c === HYPHEN) {
state = S.END;
flags = 0;
} else if (!(flags & F.LAST_BOUNDARY) && c === LF) {
index = 0;
callback('onPartBegin');
state = S.HEADER_FIELD_START;
} else {
return;
}
break;
}
if (c !== boundary[index + 2]) {
index = -2;
}
if (c === boundary[index + 2]) {
index++;
}
break;
case S.HEADER_FIELD_START:
state = S.HEADER_FIELD;
mark('onHeaderField');
index = 0;
// falls through
case S.HEADER_FIELD:
if (c === CR) {
clear('onHeaderField');
state = S.HEADERS_ALMOST_DONE;
break;
}
index++;
if (c === HYPHEN) {
break;
}
if (c === COLON) {
if (index === 1) {
// empty header field
return;
}
dataCallback('onHeaderField', true);
state = S.HEADER_VALUE_START;
break;
}
cl = lower(c);
if (cl < A || cl > Z) {
return;
}
break;
case S.HEADER_VALUE_START:
if (c === SPACE) {
break;
}
mark('onHeaderValue');
state = S.HEADER_VALUE;
// falls through
case S.HEADER_VALUE:
if (c === CR) {
dataCallback('onHeaderValue', true);
callback('onHeaderEnd');
state = S.HEADER_VALUE_ALMOST_DONE;
}
break;
case S.HEADER_VALUE_ALMOST_DONE:
if (c !== LF) {
return;
}
state = S.HEADER_FIELD_START;
break;
case S.HEADERS_ALMOST_DONE:
if (c !== LF) {
return;
}
callback('onHeadersEnd');
state = S.PART_DATA_START;
break;
case S.PART_DATA_START:
state = S.PART_DATA;
mark('onPartData');
// falls through
case S.PART_DATA:
previousIndex = index;
if (index === 0) {
// boyer-moore derrived algorithm to safely skip non-boundary data
i += boundaryEnd;
while (i < bufferLength && !(data[i] in boundaryChars)) {
i += boundaryLength;
}
i -= boundaryEnd;
c = data[i];
}
if (index < boundary.length) {
if (boundary[index] === c) {
if (index === 0) {
dataCallback('onPartData', true);
}
index++;
} else {
index = 0;
}
} else if (index === boundary.length) {
index++;
if (c === CR) {
// CR = part boundary
flags |= F.PART_BOUNDARY;
} else if (c === HYPHEN) {
// HYPHEN = end boundary
flags |= F.LAST_BOUNDARY;
} else {
index = 0;
}
} else if (index - 1 === boundary.length) {
if (flags & F.PART_BOUNDARY) {
index = 0;
if (c === LF) {
// unset the PART_BOUNDARY flag
flags &= ~F.PART_BOUNDARY;
callback('onPartEnd');
callback('onPartBegin');
state = S.HEADER_FIELD_START;
break;
}
} else if (flags & F.LAST_BOUNDARY) {
if (c === HYPHEN) {
callback('onPartEnd');
state = S.END;
flags = 0;
} else {
index = 0;
}
} else {
index = 0;
}
}
if (index > 0) {
// when matching a possible boundary, keep a lookbehind reference
// in case it turns out to be a false lead
lookbehind[index - 1] = c;
} else if (previousIndex > 0) {
// if our boundary turned out to be rubbish, the captured lookbehind
// belongs to partData
const _lookbehind = new Uint8Array(lookbehind.buffer, lookbehind.byteOffset, lookbehind.byteLength);
callback('onPartData', 0, previousIndex, _lookbehind);
previousIndex = 0;
mark('onPartData');
// reconsider the current character even so it interrupted the sequence
// it could be the beginning of a new sequence
i--;
}
break;
case S.END:
break;
default:
throw new Error(`Unexpected state entered: ${state}`);
}
}
dataCallback('onHeaderField');
dataCallback('onHeaderValue');
dataCallback('onPartData');
// Update properties for the next call
this.index = index;
this.state = state;
this.flags = flags;
}
end() {
if ((this.state === S.HEADER_FIELD_START && this.index === 0) ||
(this.state === S.PART_DATA && this.index === this.boundary.length)) {
this.onPartEnd();
} else if (this.state !== S.END) {
throw new Error('MultipartParser.end(): stream ended unexpectedly');
}
}
}
function _fileName(headerValue) {
// matches either a quoted-string or a token (RFC 2616 section 19.5.1)
const m = headerValue.match(/\bfilename=("(.*?)"|([^()<>@,;:\\"/[\]?={}\s\t]+))($|;\s)/i);
if (!m) {
return;
}
const match = m[2] || m[3] || '';
let filename = match.slice(match.lastIndexOf('\\') + 1);
filename = filename.replace(/%22/g, '"');
filename = filename.replace(/&#(\d{4});/g, (m, code) => {
return String.fromCharCode(code);
});
return filename;
}
async function toFormData(Body, ct) {
if (!/multipart/i.test(ct)) {
throw new TypeError('Failed to fetch');
}
const m = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
if (!m) {
throw new TypeError('no or bad content-type header, no multipart boundary');
}
const parser = new MultipartParser(m[1] || m[2]);
let headerField;
let headerValue;
let entryValue;
let entryName;
let contentType;
let filename;
const entryChunks = [];
const formData = new formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__/* .FormData */ .Ct();
const onPartData = ui8a => {
entryValue += decoder.decode(ui8a, {stream: true});
};
const appendToFile = ui8a => {
entryChunks.push(ui8a);
};
const appendFileToFormData = () => {
const file = new fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__/* .File */ .$B(entryChunks, filename, {type: contentType});
formData.append(entryName, file);
};
const appendEntryToFormData = () => {
formData.append(entryName, entryValue);
};
const decoder = new TextDecoder('utf-8');
decoder.decode();
parser.onPartBegin = function () {
parser.onPartData = onPartData;
parser.onPartEnd = appendEntryToFormData;
headerField = '';
headerValue = '';
entryValue = '';
entryName = '';
contentType = '';
filename = null;
entryChunks.length = 0;
};
parser.onHeaderField = function (ui8a) {
headerField += decoder.decode(ui8a, {stream: true});
};
parser.onHeaderValue = function (ui8a) {
headerValue += decoder.decode(ui8a, {stream: true});
};
parser.onHeaderEnd = function () {
headerValue += decoder.decode();
headerField = headerField.toLowerCase();
if (headerField === 'content-disposition') {
// matches either a quoted-string or a token (RFC 2616 section 19.5.1)
const m = headerValue.match(/\bname=("([^"]*)"|([^()<>@,;:\\"/[\]?={}\s\t]+))/i);
if (m) {
entryName = m[2] || m[3] || '';
}
filename = _fileName(headerValue);
if (filename) {
parser.onPartData = appendToFile;
parser.onPartEnd = appendFileToFormData;
}
} else if (headerField === 'content-type') {
contentType = headerValue;
}
headerValue = '';
headerField = '';
};
for await (const chunk of Body) {
parser.write(chunk);
}
parser.end();
return formData;
}
/***/ })
};
//# sourceMappingURL=37.index.js.map
+1
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+378 -376
View File
File diff suppressed because it is too large Load Diff
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+28552
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+1087
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+2900
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+13 -13
View File
@@ -1,7 +1,7 @@
import './sourcemap-register.cjs';import { createRequire as __WEBPACK_EXTERNAL_createRequire } from "module";
/******/ var __webpack_modules__ = ({
/***/ 3748:
/***/ 2182:
/***/ ((module, exports, __nccwpck_require__) => {
@@ -389,7 +389,7 @@ var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
(() => {
;// CONCATENATED MODULE: ./.yarn/cache/ufo-npm-1.1.1-5caba43c85-6bd210ed93.zip/node_modules/ufo/dist/index.mjs
;// CONCATENATED MODULE: ./node_modules/ufo/dist/index.mjs
const n = /[^\0-\x7E]/;
const t = /[\x2E\u3002\uFF0E\uFF61]/g;
const o = {
@@ -861,7 +861,7 @@ function stringifyParsedURL(parsed) {
;// CONCATENATED MODULE: ./.yarn/cache/radix3-npm-1.0.0-a7f8c376b3-91086baa4d.zip/node_modules/radix3/dist/index.mjs
;// CONCATENATED MODULE: ./node_modules/radix3/dist/index.mjs
const NODE_TYPES = {
NORMAL: 0,
WILDCARD: 1,
@@ -1073,7 +1073,7 @@ function _routerNodeToTable(initialPath, initialNode) {
;// CONCATENATED MODULE: ./.yarn/cache/defu-npm-6.1.2-65c0503295-2ec0ff8414.zip/node_modules/defu/dist/defu.mjs
;// CONCATENATED MODULE: ./node_modules/defu/dist/defu.mjs
function isObject(value) {
return value !== null && typeof value === "object";
}
@@ -1130,7 +1130,7 @@ const defuArrayFn = createDefu((object, key, currentValue) => {
;// CONCATENATED MODULE: ./.yarn/cache/h3-npm-1.6.2-8f413e9ff2-3a876f414d.zip/node_modules/h3/dist/index.mjs
;// CONCATENATED MODULE: ./node_modules/h3/dist/index.mjs
@@ -2582,7 +2582,7 @@ const external_node_os_namespaceObject = __WEBPACK_EXTERNAL_createRequire(import
;// CONCATENATED MODULE: external "tty"
const external_tty_namespaceObject = __WEBPACK_EXTERNAL_createRequire(import.meta.url)("tty");
var external_tty_namespaceObject_0 = /*#__PURE__*/__nccwpck_require__.t(external_tty_namespaceObject, 2);
;// CONCATENATED MODULE: ./.yarn/cache/colorette-npm-2.0.19-f73dfe6a4e-888cf5493f.zip/node_modules/colorette/index.js
;// CONCATENATED MODULE: ./node_modules/colorette/index.js
const {
@@ -2736,7 +2736,7 @@ const {
;// CONCATENATED MODULE: external "node:net"
const external_node_net_namespaceObject = __WEBPACK_EXTERNAL_createRequire(import.meta.url)("node:net");
;// CONCATENATED MODULE: ./.yarn/cache/get-port-please-npm-3.0.1-b00ea347f9-a5de771314.zip/node_modules/get-port-please/dist/index.mjs
;// CONCATENATED MODULE: ./node_modules/get-port-please/dist/index.mjs
@@ -2989,13 +2989,13 @@ async function findPort(ports, host, _verbose = false, _random = true) {
// EXTERNAL MODULE: ./.yarn/cache/http-shutdown-npm-1.2.2-e4fdf6986c-5dccd94f4f.zip/node_modules/http-shutdown/index.js
var http_shutdown = __nccwpck_require__(3748);
// EXTERNAL MODULE: ./node_modules/http-shutdown/index.js
var http_shutdown = __nccwpck_require__(2182);
;// CONCATENATED MODULE: external "node:child_process"
const external_node_child_process_namespaceObject = __WEBPACK_EXTERNAL_createRequire(import.meta.url)("node:child_process");
// EXTERNAL MODULE: external "node:path"
var external_node_path_ = __nccwpck_require__(9411);
;// CONCATENATED MODULE: ./.yarn/cache/listhen-npm-1.0.4-9812273d67-9ab54b9d23.zip/node_modules/listhen/dist/index.mjs
;// CONCATENATED MODULE: ./node_modules/listhen/dist/index.mjs
@@ -3144,7 +3144,7 @@ const baseOpen = async (options) => {
try {
(0,external_node_fs_namespaceObject.writeFileSync)(
(0,external_node_path_.join)(external_node_os_namespaceObject.tmpdir(), "xdg-open"),
await __nccwpck_require__.e(/* import() */ 230).then(__nccwpck_require__.bind(__nccwpck_require__, 4230)).then((r) => r.xdgOpenScript()),
await __nccwpck_require__.e(/* import() */ 358).then(__nccwpck_require__.bind(__nccwpck_require__, 4358)).then((r) => r.xdgOpenScript()),
"utf8"
);
(0,external_node_fs_namespaceObject.chmodSync)(
@@ -3414,7 +3414,7 @@ async function listen(handle, options_ = {}) {
return (0,external_node_util_.promisify)(server.shutdown)();
};
if (options_.clipboard) {
const clipboardy = await __nccwpck_require__.e(/* import() */ 951).then(__nccwpck_require__.bind(__nccwpck_require__, 3951)).then((r) => r.default || r);
const clipboardy = await __nccwpck_require__.e(/* import() */ 902).then(__nccwpck_require__.bind(__nccwpck_require__, 9902)).then((r) => r.default || r);
await clipboardy.write(getURL()).catch(() => {
options_.clipboard = false;
});
@@ -3473,7 +3473,7 @@ async function resolveCert(options, host) {
cert: await r(options.cert)
};
}
const { generateCA, generateSSLCert } = await __nccwpck_require__.e(/* import() */ 728).then(__nccwpck_require__.bind(__nccwpck_require__, 1728));
const { generateCA, generateSSLCert } = await __nccwpck_require__.e(/* import() */ 32).then(__nccwpck_require__.bind(__nccwpck_require__, 6032));
const ca = await generateCA();
const cert = await generateSSLCert({
caCert: ca.cert,
+1 -1
View File
File diff suppressed because one or more lines are too long
+8 -9
View File
@@ -11,15 +11,18 @@
"all": "yarn run build && yarn run prettier:check && yarn run lint && yarn run package && yarn run package:mockserver && yarn run test",
"build": "tsc -p tsconfig.json",
"build:image": "docker build -t speckle/speckle-automate-github-action:local .",
"coverage": "vitest run --coverage --run",
"lint": "eslint **/*.ts",
"lint": "eslint src/**/*.ts",
"package": "ncc build --target es2020 --source-map --license licenses.txt -o dist/action src/main.ts",
"package:mockserver": "ncc build --target es2020 --source-map --license licenses.txt -o dist/testing/mockserver src/tests/mock-server.ts",
"precommit": "pre-commit run --all-files",
"prettier:check": "prettier --check '**/*.ts'",
"prettier:fix": "prettier --write '**/*.ts'",
"run:mockserver": "node dist/testing/mockserver/index.js",
"test": "vitest --run"
"test": "vitest --run --coverage",
"test:watch": "vitest"
},
"engines": {
"node": "^16.19.1"
},
"dependencies": {
"@actions/core": "^1.10.0",
@@ -30,13 +33,10 @@
"devDependencies": {
"@types/js-yaml": "^4.0.5",
"@types/node": "^18.15.5",
"@types/sinon": "^10.0.13",
"@types/sinon-chai": "^3.2.9",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"@vercel/ncc": "^0.36.1",
"@vitest/coverage-c8": "^0.29.7",
"chai": "^4.3.7",
"@vitest/coverage-istanbul": "^0.29.7",
"eslint": "^8.36.0",
"eslint-import-resolver-typescript": "^3.5.3",
"eslint-plugin-eslint-comments": "^3.2.0",
@@ -51,11 +51,10 @@
"h3": "^1.6.2",
"js-yaml": "^4.1.0",
"listhen": "^1.0.4",
"msw": "^1.2.0",
"pino": "^8.11.0",
"pino-pretty": "^10.0.0",
"prettier": "^2.8.6",
"sinon": "^15.0.2",
"sinon-chai": "^3.7.0",
"typescript": "^5.0.2",
"vite": "^4.2.1",
"vitest": "^0.29.7"
+175
View File
@@ -0,0 +1,175 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { setupServer } from 'msw/node'
import { rest } from 'msw'
import { getMinimalSpeckleFunctionExample } from '../schema/specklefunction.spec.js'
import httpClient from './client.js'
import { getLogger } from '../tests/logger.js'
import { SpeckleFunction } from '../schema/specklefunction.js'
import { ValidationError } from 'zod-validation-error'
describe('client', () => {
const server = setupServer()
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' })
})
afterAll(() => {
server.close()
})
afterEach(() => {
server.resetHandlers()
})
describe('postManifest', () => {
describe('valid input', () => {
it('should respond with image name, function id, and version id', async () => {
server.use(
rest.post(
'https://success.automate.speckle.example.org/api/v1/functions',
async (req, res, ctx) => {
expect(await req.json()).toStrictEqual({
functionId: null,
url: 'https://github.com/specklesystems/speckle-automate-examples',
path: 'examples/minimal',
ref: 'main',
commitSha: '1234567890',
manifest: getMinimalSpeckleFunctionExample()
})
expect(req.headers.get('Authorization')).to.equal('Bearer supersecret')
const response = await res(
ctx.status(201),
ctx.json({
functionId: 'minimalfunctionid',
versionId: 'minimalversionid',
imageName: 'speckle/minimalfunctionid:minimalversionid'
})
)
return response
}
)
)
const test = httpClient.postManifest(
'https://success.automate.speckle.example.org',
'supersecret',
{
functionId: null,
path: 'examples/minimal',
url: 'https://github.com/specklesystems/speckle-automate-examples',
ref: 'main',
commitSha: '1234567890',
manifest: getMinimalSpeckleFunctionExample() as SpeckleFunction
},
getLogger()
)
await expect(test).resolves.toStrictEqual({
functionId: 'minimalfunctionid',
versionId: 'minimalversionid',
imageName: 'speckle/minimalfunctionid:minimalversionid'
})
})
})
describe('server responds with a 500 HTTP Status Code', () => {
it('should throw an error', async () => {
server.use(
rest.post(
'https://failure.automate.speckle.example.org/api/v1/functions',
async (req, res, ctx) => {
const response = await res(ctx.status(500))
return response
}
)
)
await expect(
httpClient.postManifest(
'https://failure.automate.speckle.example.org',
'sometoken',
{
functionId: null,
path: '',
url: '',
ref: '',
commitSha: '',
manifest: getMinimalSpeckleFunctionExample() as SpeckleFunction
},
getLogger()
)
).rejects.toThrow()
})
})
describe('server responds with an unexpected response body', () => {
it('should throw an error', async () => {
server.use(
rest.post(
'https://unexpectedresponse.automate.speckle.example.org/api/v1/functions',
async (req, res, ctx) => {
const response = await res(
ctx.status(201),
ctx.json({
unexpected: 'unexpected'
})
)
return response
}
)
)
await expect(
httpClient.postManifest(
'https://unexpectedresponse.automate.speckle.example.org',
'sometoken',
{
functionId: null,
path: '',
url: '',
ref: '',
commitSha: '',
manifest: getMinimalSpeckleFunctionExample() as SpeckleFunction
},
getLogger()
)
).rejects.toThrow(ValidationError)
})
})
describe('invalid input', () => {
describe('empty url', () => {
it('should throw an error', async () => {
expect(
httpClient.postManifest(
'',
'supersecret',
{
functionId: null,
path: 'examples/minimal',
url: 'https://github.com/specklesystems/speckle-automate-examples',
ref: 'main',
commitSha: '1234567890',
manifest: getMinimalSpeckleFunctionExample() as SpeckleFunction
},
getLogger()
)
).rejects.toThrow(Error)
})
})
describe('empty token', () => {
it('should throw an error', async () => {
expect(
httpClient.postManifest(
'https://example.org',
'',
{
functionId: null,
path: 'examples/minimal',
url: 'https://github.com/specklesystems/speckle-automate-examples',
ref: 'main',
commitSha: '1234567890',
manifest: getMinimalSpeckleFunctionExample() as SpeckleFunction
},
getLogger()
)
).rejects.toThrow(Error)
})
})
})
})
})
+9 -6
View File
@@ -4,7 +4,7 @@ import {
SpeckleFunctionPostRequestBody
} from './schema.js'
import { URL } from 'url'
import { handleZodError } from '../utils/errors.js'
import { handleZodError } from '../schema/errors.js'
import { Logger } from '../logging/logger.js'
import fetch from 'node-fetch'
@@ -13,15 +13,14 @@ export default {
url: string,
token: string,
body: SpeckleFunctionPostRequestBody,
logger: Logger
logger: Logger,
_fetch: typeof fetch = fetch
): Promise<SpeckleFunctionPostResponseBody> => {
if (!url) throw new Error('Speckle Server URL is required')
if (!token) throw new Error('Speckle Token is required')
if (!body) throw new Error('Speckle Function Post Request Body is required')
if (!logger) throw new Error('Logger is required')
const endpointUrl = new URL('/api/v1/functions', url)
const response = await fetch(endpointUrl.href, {
const response = await _fetch(endpointUrl.href, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -31,7 +30,11 @@ export default {
})
if (!response.ok) {
throw new Error(`Failed to register Speckle Function: ${response.statusText}`)
throw new Error(
`Failed to register Speckle Function. Status Code: ${
response.status
}. Status Text: ${response.statusText}. Response Body: ${await response.text()}`
) //FIXME use a more specific error type
}
let responseBody: SpeckleFunctionPostResponseBody
+105
View File
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { SpeckleFunctionPostRequestBodySchema } from './schema.js'
import { ZodError } from 'zod'
import {
getMinimalSpeckleFunctionExample,
NonConformantSpeckleFunction
} from '../schema/specklefunction.spec.js'
type NonConformanSpeckleFunctionPostRequestBody = {
functionId: string | null | undefined
url: string | undefined
path: string | undefined
ref: string | undefined
commitSha: string | undefined
manifest: NonConformantSpeckleFunction | undefined
}
function getMinimumRequestBody(): NonConformanSpeckleFunctionPostRequestBody {
return {
functionId: null,
url: 'https://example.org',
path: '.',
ref: 'main',
commitSha: 'sha123',
manifest: getMinimalSpeckleFunctionExample()
}
}
describe('schema', () => {
let minimal: NonConformanSpeckleFunctionPostRequestBody
beforeEach(() => {
minimal = getMinimumRequestBody()
})
describe('Speckle Function Post Request Body Schema', () => {
it('cannot be empty', async () => {
expect(() => SpeckleFunctionPostRequestBodySchema.parse('')).toThrow(ZodError)
})
describe('functionId', () => {
it('can be null', () => {
minimal.functionId = null
expect(() => SpeckleFunctionPostRequestBodySchema.parse(minimal)).not.toThrow(
ZodError
)
})
it('cannot be an empty string', () => {
minimal.functionId = ''
expect(() => SpeckleFunctionPostRequestBodySchema.parse(minimal)).toThrow(
ZodError
)
})
})
describe('url', () => {
it('cannot be missing', () => {
minimal.url = ''
expect(() => SpeckleFunctionPostRequestBodySchema.parse(minimal)).toThrow(
ZodError
)
})
it('cannot be a url with a username', () => {
minimal.url = 'https://user@example.org'
expect(() => SpeckleFunctionPostRequestBodySchema.parse(minimal)).toThrow(
ZodError
)
})
it('cannot be a url with a password', () => {
minimal.url = 'https://:password@example.org'
expect(() => SpeckleFunctionPostRequestBodySchema.parse(minimal)).toThrow(
ZodError
)
})
it('cannot have a url and password', () => {
minimal.url = 'https://user:password@example.org'
expect(() => SpeckleFunctionPostRequestBodySchema.parse(minimal)).toThrow(
ZodError
)
})
})
describe('path', () => {
it('cannot be empty', () => {
minimal.path = ''
expect(() => SpeckleFunctionPostRequestBodySchema.parse(minimal)).toThrow(
ZodError
)
})
})
describe('ref', () => {
it('cannot be empty', () => {
minimal.ref = ''
expect(() => SpeckleFunctionPostRequestBodySchema.parse(minimal)).toThrow(
ZodError
)
})
})
describe('commitSha', () => {
it('cannot be empty', () => {
minimal.commitSha = ''
expect(() => SpeckleFunctionPostRequestBodySchema.parse(minimal)).toThrow(
ZodError
)
})
})
})
})
+5 -4
View File
@@ -1,9 +1,9 @@
import { z } from 'zod'
import { SpeckleFunctionSchema } from '../schema/speckleFunction.js'
import { SpeckleFunctionSchema } from '../schema/specklefunction.js'
import { URL } from 'url'
export const SpeckleFunctionPostRequestBodySchema = z.object({
functionId: z.string().optional(),
functionId: z.union([z.string().nonempty(), z.null()]),
url: z
.string()
.url()
@@ -29,6 +29,7 @@ export const SpeckleFunctionPostRequestBodySchema = z.object({
),
path: z
.string()
.nonempty()
.refine(
() => true, //TODO validate it is a path, and the path is not a directory traversal attack out of the source code directory (such as ../../etc/passwd). We can take much of the directory traversal code from build-instructions-step.
{
@@ -36,8 +37,8 @@ export const SpeckleFunctionPostRequestBodySchema = z.object({
}
)
.default('.'),
ref: z.string(),
commitSha: z.string(),
ref: z.string().nonempty(),
commitSha: z.string().nonempty(),
manifest: SpeckleFunctionSchema
})
@@ -2,6 +2,10 @@
import { promises as fsPromises } from 'fs'
import yaml from 'js-yaml'
export type FileSystem = {
loadYaml: (filePath: string) => Promise<unknown>
}
const fileUtil = {
loadYaml: async (filePath: string) => {
return yaml.load(await fsPromises.readFile(filePath, 'utf8'))
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getMinimalSpeckleFunctionExample } from '../schema/specklefunction.spec.js'
import { getLogger } from '../tests/logger.js'
import { findAndParseManifest } from './parser.js'
import { ValidationError } from 'zod-validation-error'
describe('filesystem/parser', () => {
afterEach(async () => {
vi.restoreAllMocks()
})
describe('No Yaml file', () => {
it('should throw', async () => {
expect(async () =>
findAndParseManifest('doesNotExist', {
logger: getLogger(),
fileSystem: {
loadYaml: async () => {
throw new Error('File does not exist')
}
}
})
).rejects.toThrow(Error)
})
})
})
describe('Minimal yaml file', () => {
it('should parse', async () => {
const speckleFunction = await findAndParseManifest('examples/minimal', {
logger: getLogger(),
fileSystem: { loadYaml: async () => getMinimalSpeckleFunctionExample() }
})
expect(speckleFunction).toBeDefined()
expect(speckleFunction.metadata.name).toBe('minimal')
})
describe('with filename in path', () => {
it('should parse', async () => {
const speckleFunction = await findAndParseManifest(
'examples/minimal/specklefunction.yaml',
{
logger: getLogger(),
fileSystem: {
loadYaml: async () => getMinimalSpeckleFunctionExample()
}
}
)
expect(speckleFunction).toBeDefined()
expect(speckleFunction.metadata.name).toBe('minimal')
})
})
describe('Invalid yaml file', () => {
it('should throw', async () => {
expect(async () =>
findAndParseManifest('src/tests/data/invalid', {
logger: getLogger(),
fileSystem: {
loadYaml: async () => {
return 'invalid: yaml'
}
}
})
).rejects.toThrow(ValidationError)
})
})
})
+32
View File
@@ -0,0 +1,32 @@
import { FileSystem } from './files.js'
import { SpeckleFunction, SpeckleFunctionSchema } from '../schema/specklefunction.js'
import * as path from 'path'
import { Logger } from '../logging/logger.js'
import { handleZodError } from '../schema/errors.js'
export type ParserOptions = {
logger: Logger
fileSystem: FileSystem
}
export async function findAndParseManifest(
pathToSpeckleFunctionFile: string,
opts: ParserOptions
): Promise<SpeckleFunction> {
if (!pathToSpeckleFunctionFile.toLocaleLowerCase().endsWith('specklefunction.yaml')) {
pathToSpeckleFunctionFile = path.join(
pathToSpeckleFunctionFile,
'specklefunction.yaml'
)
}
const speckleFunctionRaw = await opts.fileSystem.loadYaml(pathToSpeckleFunctionFile)
let speckleFunction: SpeckleFunction
try {
speckleFunction = await SpeckleFunctionSchema.parseAsync(speckleFunctionRaw)
} catch (err) {
throw handleZodError(err, opts.logger)
}
return speckleFunction
}
+7 -2
View File
@@ -1,5 +1,6 @@
import * as core from '@actions/core'
import { registerSpeckleFunction } from './speckleautomate.js'
import { registerSpeckleFunction } from './registerspecklefunction.js'
import fileUtil from './filesystem/files.js'
async function run(): Promise<void> {
try {
@@ -8,17 +9,21 @@ async function run(): Promise<void> {
core.setSecret(speckleTokenRaw)
const speckleFunctionPathRaw = core.getInput('speckle_function_path')
const speckleFunctionIdRaw = core.getInput('speckle_function_id')
const gitServerUrl = process.env.GITHUB_SERVER_URL
const gitRepository = process.env.GITHUB_REPOSITORY
const gitRefRaw = process.env.GITHUB_REF_NAME
const gitCommitShaRaw = process.env.GITHUB_SHA
const { imageName, functionId, versionId } = await registerSpeckleFunction({
speckleServerUrl: speckleServerUrlRaw,
speckleToken: speckleTokenRaw,
speckleFunctionRepositoryUrl: `${gitServerUrl}/${gitRepository}.git`,
speckleFunctionPath: speckleFunctionPathRaw,
speckleFunctionId: speckleFunctionIdRaw,
ref: gitRefRaw,
commitsha: gitCommitShaRaw,
logger: core
logger: core,
fileSystem: fileUtil
})
core.setOutput('function_id', functionId)
@@ -1,27 +1,30 @@
import client from './client/client.js'
import { Logger } from './logging/logger.js'
import fileUtil from './utils/files.js'
import {
SpeckleServerUrlSchema,
SpeckleTokenSchema,
SpeckleFunctionPathSchema,
SpeckleFunctionIdSchema,
GitRefSchema,
GitCommitShaSchema
GitCommitShaSchema,
SpeckleFunctionRepositorySchema
} from './schema/inputs.js'
import { SpeckleFunction, SpeckleFunctionSchema } from './schema/speckleFunction.js'
import * as path from 'path'
import { handleZodError } from './utils/errors.js'
import { handleZodError } from './schema/errors.js'
import { SpeckleFunctionPostRequestBody } from './client/schema.js'
import { findAndParseManifest } from './filesystem/parser.js'
import { FileSystem } from './filesystem/files.js'
type ProcessOptions = {
speckleServerUrl: string | undefined
speckleToken: string
speckleFunctionRepositoryUrl: string
ref: string | undefined
commitsha: string | undefined
speckleFunctionPath: string | undefined
speckleFunctionId?: string | undefined
logger: Logger
fileSystem: FileSystem
}
type ProcessResult = {
@@ -35,6 +38,7 @@ export async function registerSpeckleFunction(
): Promise<ProcessResult> {
let speckleServerUrl: string
let speckleToken: string
let speckleFunctionRepositoryUrl: string
let speckleFunctionPath: string
let speckleFunctionId: string | undefined
let gitRef: string
@@ -42,6 +46,9 @@ export async function registerSpeckleFunction(
try {
speckleServerUrl = SpeckleServerUrlSchema.parse(opts.speckleServerUrl)
speckleToken = SpeckleTokenSchema.parse(opts.speckleToken)
speckleFunctionRepositoryUrl = SpeckleFunctionRepositorySchema.parse(
opts.speckleFunctionRepositoryUrl
)
speckleFunctionPath = SpeckleFunctionPathSchema.parse(opts.speckleFunctionPath)
speckleFunctionId = SpeckleFunctionIdSchema.parse(opts.speckleFunctionId)
gitRef = GitRefSchema.parse(opts.ref)
@@ -50,16 +57,19 @@ export async function registerSpeckleFunction(
throw handleZodError(err, opts.logger)
}
opts.logger.info(`Speckle Server URL: ${speckleServerUrl}`)
opts.logger.info(`Speckle Server URL: '${speckleServerUrl}'`)
//token is masked in the logs, so no need to print it here.
opts.logger.info(`Speckle Function Path: ${speckleFunctionPath}`)
opts.logger.info(`Speckle Function ID: ${speckleFunctionId}`)
opts.logger.info(`Speckle Function Path: '${speckleFunctionPath}'`)
opts.logger.info(`Speckle Function ID (optional): '${speckleFunctionId}'`)
const manifest = await findAndParseManifest(opts.logger, speckleFunctionPath)
const manifest = await findAndParseManifest(speckleFunctionPath, {
logger: opts.logger,
fileSystem: opts.fileSystem
})
const body: SpeckleFunctionPostRequestBody = {
functionId: speckleFunctionId || undefined,
url: speckleServerUrl,
functionId: speckleFunctionId || null,
url: speckleFunctionRepositoryUrl,
path: speckleFunctionPath,
ref: gitRef,
commitSha: gitCommitSha,
@@ -77,30 +87,3 @@ export async function registerSpeckleFunction(
)
return response
}
async function findAndParseManifest(logger: Logger, pathToSpeckleFunctionFile: string) {
if (!pathToSpeckleFunctionFile.toLocaleLowerCase().endsWith('specklefunction.yaml')) {
pathToSpeckleFunctionFile = path.join(
pathToSpeckleFunctionFile,
'specklefunction.yaml'
)
}
let speckleFunctionRaw: unknown
try {
speckleFunctionRaw = await fileUtil.loadYaml(pathToSpeckleFunctionFile)
} catch (err) {
if (err instanceof Error) {
logger.error(err)
}
throw err
}
let speckleFunction: SpeckleFunction
try {
speckleFunction = await SpeckleFunctionSchema.parseAsync(speckleFunctionRaw)
} catch (err) {
throw handleZodError(err, logger)
}
return speckleFunction
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
import { handleZodError } from './errors.js'
import { ZodError } from 'zod'
import { ValidationError } from 'zod-validation-error'
describe('errors', () => {
describe('with ZodError', () => {
it('logs and throws', async () => {
const errorFn = vi.fn()
const logger = { error: errorFn, info: vi.fn(), warn: vi.fn(), debug: vi.fn() }
expect(() => handleZodError(new ZodError([]), logger)).toThrow(ValidationError)
expect(errorFn).toHaveBeenCalledTimes(1)
})
})
describe('with unknown error', () => {
it('throws', async () => {
const errorFn = vi.fn()
const logger = { error: errorFn, info: vi.fn(), warn: vi.fn(), debug: vi.fn() }
expect(() => handleZodError(new Error('unknown'), logger)).toThrow(Error)
expect(errorFn).toHaveBeenCalledTimes(0)
})
})
})
@@ -1,46 +1,39 @@
import { expect } from 'chai'
import { describe, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
SpeckleFunctionPathSchema,
SpeckleServerUrlSchema,
SpeckleTokenSchema
} from './schema/inputs.js'
} from './inputs.js'
import { ZodError } from 'zod'
describe('schema', () => {
describe('Speckle Server URL', () => {
it('cannot be empty', async () => {
expect(SpeckleServerUrlSchema.parse.bind(SpeckleServerUrlSchema, '')).to.throw(
ZodError
)
expect(() => SpeckleServerUrlSchema.parse('')).toThrow(ZodError)
})
})
describe('Speckle Token', () => {
it('cannot be empty', async () => {
expect(SpeckleTokenSchema.parse.bind(SpeckleTokenSchema, '')).to.throw(ZodError)
expect(() => SpeckleTokenSchema.parse('')).toThrow(ZodError)
})
})
describe('Speckle Function Path', () => {
it('cannot be empty', async () => {
expect(
SpeckleFunctionPathSchema.parse.bind(SpeckleFunctionPathSchema, '')
).to.throw(ZodError)
expect(() => SpeckleFunctionPathSchema.parse('')).toThrow(ZodError)
})
it('cannot be an absolute path', async () => {
expect(
SpeckleFunctionPathSchema.parse.bind(SpeckleFunctionPathSchema, '/')
).to.throw(ZodError)
expect(() => SpeckleFunctionPathSchema.parse('/')).toThrow(ZodError)
})
it('can be a nested relative path', async () => {
expect(SpeckleFunctionPathSchema.parse('src/main.ts')).to.equal('src/main.ts')
expect(SpeckleFunctionPathSchema.parse('src/main.ts')).toBe('src/main.ts')
})
it('can have a leading dot slash', async () => {
expect(SpeckleFunctionPathSchema.parse('./src/main.ts')).to.equal('./src/main.ts')
expect(SpeckleFunctionPathSchema.parse('./src/main.ts')).toBe('./src/main.ts')
})
it('can be at the current directory', async () => {
expect(SpeckleFunctionPathSchema.parse('.')).to.equal('.')
expect(SpeckleFunctionPathSchema.parse('.')).toBe('.')
})
})
})
+1
View File
@@ -2,6 +2,7 @@ import { z } from 'zod'
export const SpeckleServerUrlSchema = z.string().url().nonempty()
export const SpeckleTokenSchema = z.string().nonempty()
export const SpeckleFunctionRepositorySchema = z.string().nonempty() //TODO validate this as a git+https, https, or ssh url
export const SpeckleFunctionPathSchema = z
.string()
.nonempty()
+123
View File
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { SpeckleFunctionSchema } from './specklefunction.js'
import { ZodError } from 'zod'
export type NonConformantSpeckleFunction = {
apiVersion: string | undefined
kind: string | undefined
metadata:
| {
name: string | undefined
}
| undefined
spec:
| {
steps:
| [
{
name: string | undefined
}
]
| []
| undefined
}
| undefined
}
export function getMinimalSpeckleFunctionExample(): NonConformantSpeckleFunction {
return {
apiVersion: 'speckle.systems/v1alpha1',
kind: 'SpeckleFunction',
metadata: {
name: 'minimal'
},
spec: {
steps: [
{
name: 'step1'
}
]
}
}
}
describe('speckle function schema', () => {
let minimal: NonConformantSpeckleFunction
beforeEach(() => {
minimal = getMinimalSpeckleFunctionExample()
})
describe('Speckle Function', () => {
it('cannot be empty', async () => {
expect(() => SpeckleFunctionSchema.parse({})).toThrow(ZodError)
})
describe('apiVersion', () => {
it('cannot be missing-', async () => {
minimal.apiVersion = ''
expect(() => SpeckleFunctionSchema.parse('{}')).toThrow(ZodError)
})
it('cannot be invalid', async () => {
minimal.apiVersion = 'invalid'
expect(() => SpeckleFunctionSchema.parse(minimal)).toThrow(ZodError)
})
})
describe('kind', () => {
it('cannot be missing', async () => {
minimal.kind = ''
expect(() => SpeckleFunctionSchema.parse('/')).toThrow(ZodError)
})
it('cannot be invalid', async () => {
minimal.kind = 'invalid'
expect(() => SpeckleFunctionSchema.parse(minimal)).toThrow(ZodError)
})
})
describe('metadata', () => {
it('cannot be missing a metadata', async () => {
minimal.metadata = undefined
expect(() => SpeckleFunctionSchema.parse(minimal)).toThrow(ZodError)
})
describe('name', () => {
it('cannot be missing', async () => {
if (minimal.metadata === undefined) throw new Error('metadata is undefined') // for typescript
minimal.metadata.name = ''
expect(() => SpeckleFunctionSchema.parse(minimal)).toThrow(ZodError)
})
})
})
describe('spec', () => {
it('cannot be missing', async () => {
minimal.spec = undefined
expect(() => SpeckleFunctionSchema.parse(minimal)).toThrow(ZodError)
})
describe('steps', () => {
it('cannot be missing', async () => {
if (minimal.spec === undefined) throw new Error('spec is undefined') // for typescript
minimal.spec.steps = undefined
expect(() => SpeckleFunctionSchema.parse(minimal)).toThrow(ZodError)
})
it('must have at least one item', async () => {
if (minimal.spec === undefined) throw new Error('spec is undefined') // for typescript
minimal.spec.steps = []
expect(() => SpeckleFunctionSchema.parse(minimal)).toThrow(ZodError)
})
it('items cannot be missing a name', async () => {
if (minimal.spec === undefined) throw new Error('spec is undefined') // for typescript
if (minimal.spec.steps === undefined) throw new Error('steps is undefined') // for typescript
if (minimal.spec.steps[0] === undefined)
throw new Error('steps[0] is undefined') // for typescript
minimal.spec.steps[0].name = ''
expect(() => SpeckleFunctionSchema.parse(minimal)).toThrow(ZodError)
})
})
})
it('can be minimal', async () => {
expect(SpeckleFunctionSchema.parse(minimal)).toStrictEqual(minimal)
})
})
})
@@ -12,7 +12,7 @@ export const SpeckleFunctionSchema = z.object({
kind: z.literal(SpeckleFunctionKind),
apiVersion: z.enum([SpeckleFunctionApiVersionV1Alpha1]),
metadata: z.object({
name: z.string(),
name: z.string().nonempty(),
annotations: z
.object({
'speckle.systems/v1alpha1/publishing/status': z
@@ -47,7 +47,7 @@ export const SpeckleFunctionSchema = z.object({
steps: z
.array(
z.object({
name: z.string().optional()
name: z.string().nonempty()
//TODO
})
)
-9
View File
@@ -1,9 +0,0 @@
'use strict'
import chai from 'chai'
import sinonChai from 'sinon-chai'
chai.config.includeStack = true
chai.config.showDiff = true
chai.config.truncateThreshold = 0 // disable truncation
chai.use(sinonChai)
export default chai
+72 -54
View File
@@ -1,74 +1,92 @@
import chai from './chai-config.js'
import sinon from 'sinon'
import { describe, it, afterEach, beforeEach } from 'vitest'
import {
App,
eventHandler,
createApp,
createRouter,
H3Event,
Router,
toNodeListener
} from 'h3'
import { listen } from 'listhen'
import { getRandomPort } from 'get-port-please'
import { registerSpeckleFunction } from '../speckleautomate.js'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { setupServer } from 'msw/node'
import { rest } from 'msw'
import { registerSpeckleFunction } from '../registerspecklefunction.js'
import { getLogger } from './logger.js'
import { SpeckleFunctionPostResponseBody } from '../client/schema.js'
const expect = chai.expect
import { getMinimalSpeckleFunctionExample } from '../schema/specklefunction.spec.js'
import { ValidationError } from 'zod-validation-error'
describe('integration', () => {
const server = setupServer()
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' })
})
afterAll(() => {
server.close()
})
afterEach(() => {
sinon.restore()
server.resetHandlers()
})
describe('Load from ./examples directory', () => {
let app: App
let router: Router
let port: number
const hostname = '127.0.0.1'
beforeEach(() => {
app = createApp({ debug: false })
describe('Load from ./examples directory', async () => {
describe('registerSpeckleAutomate', async () => {
describe('valid input', async () => {
it('should respond with image name, function id, and version id', async () => {
server.use(
rest.post(
'https://integration1.automate.speckle.example.org/api/v1/functions',
async (req, res, ctx) => {
expect(req.body).toStrictEqual({
functionId: null,
url: 'https://github.com/specklesystems/speckle-automate-examples.git',
path: 'examples/minimal',
ref: 'main',
commitSha: '1234567890',
manifest: getMinimalSpeckleFunctionExample()
})
describe('registerSpeckleAutomate', () => {
it('should respond with Function name and id', async () => {
// set up the fake server
router = createRouter().post(
'/api/v1/functions',
eventHandler((event: H3Event): SpeckleFunctionPostResponseBody => {
event.node.res.statusCode = 201
event.node.res.statusMessage = 'Created'
event.node.res.setHeader('Content-Type', 'application/json')
return {
expect(req.headers.get('Authorization')).toBe('Bearer supersecret')
const response = await res(
ctx.status(201),
ctx.json({
functionId: 'minimalfunctionid',
versionId: 'minimalversionid',
imageName: 'speckle/minimalfunctionid:minimalversionid'
}
})
)
app.use(router)
port = await getRandomPort(hostname)
listen(toNodeListener(app), {
hostname,
port
})
const result = await registerSpeckleFunction({
return response
}
)
)
const result = registerSpeckleFunction({
speckleFunctionId: undefined,
speckleServerUrl: `http://${hostname}:${port}`,
speckleToken: 'token',
speckleServerUrl: 'https://integration1.automate.speckle.example.org',
speckleToken: 'supersecret',
speckleFunctionRepositoryUrl:
'https://github.com/specklesystems/speckle-automate-examples.git',
speckleFunctionPath: 'examples/minimal',
ref: 'main',
commitsha: '1234567890',
logger: getLogger()
logger: getLogger(),
fileSystem: {
loadYaml: async () => getMinimalSpeckleFunctionExample()
}
})
await expect(result).resolves.toStrictEqual({
functionId: 'minimalfunctionid',
versionId: 'minimalversionid',
imageName: 'speckle/minimalfunctionid:minimalversionid'
})
})
})
describe('invalid input', async () => {
it('should throw an error', async () => {
expect(async () =>
registerSpeckleFunction({
speckleFunctionId: undefined,
speckleServerUrl: undefined,
speckleToken: '',
speckleFunctionRepositoryUrl: '',
speckleFunctionPath: undefined,
ref: undefined,
commitsha: undefined,
logger: getLogger(),
fileSystem: {
loadYaml: async () => getMinimalSpeckleFunctionExample()
}
})
).rejects.toThrow(ValidationError)
})
expect(result.functionId).to.equal('minimalfunctionid')
expect(result.versionId).to.equal('minimalversionid')
expect(result.imageName).to.equal('speckle/minimalfunctionid:minimalversionid')
})
})
})
+1 -1
View File
@@ -10,7 +10,7 @@
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"lib": ["ESNext"],
"types": ["node"],
"skipLibCheck": true
"skipLibCheck": true //FIXME: remove this
},
"include": ["src/**/*"]
}
+34 -2
View File
@@ -1,5 +1,37 @@
export default {
import { configDefaults, defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
resolve: {
mainFields: ['module']
},
test: {
exclude: [...configDefaults.exclude, 'lib/**', 'dist/**'],
coverage: {
reporter: ['lcov', 'text', 'json', 'html'],
provider: 'istanbul',
exclude: [
'src/tests/**/*',
'src/**/*.spec.ts',
'src/**/*.spec.tsx',
'lib/**/*',
'dist/**/*',
'**/*.cjs',
'**/*.mjs',
'**/*.js'
],
lines: 95,
functions: 95,
branches: 95,
statements: 95,
resolve: {
alias: {
'@': path.resolve(__dirname, './src/')
}
}
},
define: {
'import.meta.vitest': 'undefined'
}
}
}
})
-3
View File
@@ -1,3 +0,0 @@
export default {
exclude: ['node_modules/**/*', 'lib/**/*', 'dist/**/*', '.yarn/**/*']
}
+1032 -234
View File
File diff suppressed because it is too large Load Diff