refactor: bring js-sandbox project to the monorepo

This commit is contained in:
Andrew Bastin
2021-09-25 00:59:09 +05:30
parent 9698932bde
commit d2865c637c
23 changed files with 1725 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
module.exports = {
root: true,
env: {
node: true,
jest: true,
browser: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
sourceType: "module",
requireConfigFile: false,
ecmaVersion: 2021,
},
plugins: ["prettier"],
extends: [
"prettier/prettier",
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
],
rules: {
semi: [2, "never"],
"prettier/prettier": ["warn", { semi: false }],
"import/no-named-as-default": "off",
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "off",
},
}

View File

@@ -0,0 +1,5 @@
node_modules
coverage
.husky/
dist
lib

View File

@@ -0,0 +1,8 @@
.dependabot
.github
.hoppscotch
.vscode
package-lock.json
node_modules
dist
lib

View File

@@ -0,0 +1,3 @@
module.exports = {
semi: false,
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,56 @@
<div align="center">
<a href="https://hoppscotch.io">
<img height=64 src="https://raw.githubusercontent.com/hoppscotch/hoppscotch/main/static/logo.png" alt="The Hoppscotch UFO logo" />
</a>
</div>
<div align="center">
# Hoppscotch JavaScript Sandbox <font size=2><sup>ALPHA</sup></font>
</div>
This package deals with providing a JavaScript sandbox for executing various security sensitive external scripts.
## Usage
- Install the [npm package](https://www.npmjs.com/package/@hoppscotch/js-sandbox)
```
npm install --save @hoppscotch/js-sandbox
```
## How does this work ?
This package makes use of [quickjs-empscripten](https://www.npmjs.com/package/quickjs-emscripten) for building sandboxes for running external code on Hoppscotch.
Currently implemented sandboxes:
- Hoppscotch Test Scripts
## Development
- Clone the repository
```
git clone https://github.com/hoppscotch/hopp-js-sandbox
```
- Install the package deps
```
npm install
```
- Try out the demo [`src/demo.ts`](https://github.com/hoppscotch/hopp-js-sandbox/blob/main/src/demo.ts) using
```
npm run demo
```
## Versioning
This project follows [Semantic Versioning](https://semver.org/) but as the project is still pre-1.0. The code and the public exposed API should not be considered to be fixed and stable. Things can change at any time!
## License
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see [`LICENSE`](https://github.com/hoppscotch/hopp-js-sandbox/blob/main/LICENSE) for more details.
<div align="center">
<br />
<br />
<br />
###### built with ❤︎ by the [Hoppscotch Team](https://github.com/hoppscotch) and [contributors](https://github.com/AndrewBastin/hopp-js-sandbox/graphs/contributors).
</div>

View File

@@ -0,0 +1,6 @@
export default {
preset: "ts-jest",
testEnvironment: "node",
collectCoverage: true,
setupFilesAfterEnv: ['./jest.setup.ts'],
}

View File

@@ -0,0 +1 @@
require('@relmify/jest-fp-ts');

View File

@@ -0,0 +1,59 @@
{
"name": "@hoppscotch/js-sandbox",
"version": "0.3.1",
"description": "JavaScript sandboxes for running external scripts used by Hoppscotch clients",
"main": "./lib/index.js",
"types": "./lib/",
"type": "module",
"engines": {
"node": ">=14",
"pnpm": ">=3"
},
"scripts": {
"demo": "esrun src/demo.ts",
"lint": "eslint --ext .ts,.js --ignore-path .gitignore",
"test": "npx jest",
"build": "npx tsc",
"clean": "npx tsc --build --clean",
"prepublish": "npm run build",
"do-lintfix": "pnpm run lint",
"do-test": "pnpm run test"
},
"keywords": [
"hoppscotch",
"sandbox",
"js-sandbox",
"apis",
"test-runner"
],
"author": "The Hoppscotch Team <support@hoppscotch.io> (https://hoppscotch.com/)",
"license": "MIT",
"dependencies": {
"fp-ts": "^2.11.3",
"lodash": "^4.17.21",
"quickjs-emscripten": "^0.13.0"
},
"devDependencies": {
"@digitak/esrun": "^1.2.4",
"@relmify/jest-fp-ts": "^1.1.1",
"@types/jest": "^26.0.23",
"@types/lodash": "^4.14.173",
"@types/node": "^15.12.5",
"@typescript-eslint/eslint-plugin": "^4.28.1",
"@typescript-eslint/parser": "^4.28.1",
"eslint": "^7.29.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0",
"io-ts": "^2.2.16",
"jest": "^27.0.6",
"prettier": "^2.3.2",
"pretty-quick": "^3.1.1",
"ts-jest": "^27.0.3",
"typescript": "^4.3.5"
},
"jest": {
"setupFilesAfterEnv": [
"@relmify/jest-fp-ts"
]
}
}

View File

@@ -0,0 +1,64 @@
import { execPreRequestScript } from "../preRequest"
import "@relmify/jest-fp-ts"
describe("execPreRequestScript", () => {
test("returns the updated envirionment properly", () => {
return expect(
execPreRequestScript(
`
pw.env.set("bob", "newbob")
`,
[{ key: "bob", value: "oldbob" }, { key: "foo", value: "bar" }]
)()
).resolves.toEqualRight([
{ key: "bob", value: "newbob" },
{ key: "foo", value: "bar" }
])
})
test("fails if the key is not a string", () => {
return expect(
execPreRequestScript(
`
pw.env.set(10, "newbob")
`,
[{ key: "bob", value: "oldbob" }, { key: "foo", value: "bar" }]
)()
).resolves.toBeLeft()
})
test("fails if the value is not a string", () => {
return expect(
execPreRequestScript(
`
pw.env.set("bob", 10)
`,
[{ key: "bob", value: "oldbob" }, { key: "foo", value: "bar" }]
)()
).resolves.toBeLeft()
})
test("fails for invalid syntax", () => {
return expect(
execPreRequestScript(
`
pw.env.set("bob",
`,
[{ key: "bob", value: "oldbob" }, { key: "foo", value: "bar" }]
)()
).resolves.toBeLeft()
})
test("creates new env variable if doesn't exist", () => {
return expect(
execPreRequestScript(
`
pw.env.set("foo", "bar")
`,
[]
)()
).resolves.toEqualRight(
[{ key: "foo", value: "bar" }]
)
})
})

View File

@@ -0,0 +1,98 @@
import { execTestScript, TestResponse } from "../../../test-runner"
import "@relmify/jest-fp-ts"
const fakeResponse: TestResponse = {
status: 200,
body: "hoi",
headers: []
}
describe("toBe", () => {
describe("general assertion (no negation)", () => {
test("expect equals expected passes assertion", () => {
return expect(
execTestScript(
`
pw.expect(2).toBe(2)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{ status: "pass", message: "Expected '2' to be '2'" }],
}),
])
})
test("expect not equals expected fails assertion", () => {
return expect(
execTestScript(
`
pw.expect(2).toBe(4)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{ status: "fail", message: "Expected '2' to be '4'" }],
}),
])
})
})
describe("general assertion (with negation)", () => {
test("expect equals expected fails assertion", () => {
return expect(
execTestScript(
`
pw.expect(2).not.toBe(2)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: "Expected '2' to not be '2'",
}],
}),
])
})
test("expect not equals expected passes assertion", () => {
return expect(
execTestScript(
`
pw.expect(2).not.toBe(4)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "pass",
message: "Expected '2' to not be '4'",
}],
}),
])
})
})
})
test("strict checks types", () => {
return expect(
execTestScript(
`
pw.expect(2).toBe("2")
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: "Expected '2' to be '2'",
}],
}),
])
})

View File

@@ -0,0 +1,361 @@
import { execTestScript, TestResponse } from "../../../test-runner"
import "@relmify/jest-fp-ts"
const fakeResponse: TestResponse = {
status: 200,
body: "hoi",
headers: []
}
describe("toBeLevel2xx", () => {
test("assertion passes for 200 series with no negation", async () => {
for (let i = 200; i < 300; i++) {
await expect(
execTestScript(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "pass",
message: `Expected '${i}' to be 200-level status`,
}],
}),
])
}
})
test("assertion fails for non 200 series with no negation", async () => {
for (let i = 300; i < 500; i++) {
await expect(
execTestScript(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: `Expected '${i}' to be 200-level status`,
}],
}),
])
}
})
test("give error if the expect value was not a number with no negation", async () => {
await expect(
execTestScript(`pw.expect("foo").toBeLevel2xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "error",
message: "Expected 200-level status but could not parse value 'foo'",
}],
})
])
})
test("assertion fails for 200 series with negation", async () => {
for (let i = 200; i < 300; i++) {
await expect(
execTestScript(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: `Expected '${i}' to not be 200-level status`,
}],
}),
])
}
})
test("assertion passes for non 200 series with negation", async () => {
for (let i = 300; i < 500; i++) {
await expect(
execTestScript(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "pass",
message: `Expected '${i}' to not be 200-level status`,
}],
}),
])
}
})
test("give error if the expect value was not a number with negation", async () => {
await expect(
execTestScript(`pw.expect("foo").not.toBeLevel2xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "error",
message: "Expected 200-level status but could not parse value 'foo'",
}],
})
])
})
})
describe("toBeLevel3xx", () => {
test("assertion passes for 300 series with no negation", async () => {
for (let i = 300; i < 400; i++) {
await expect(
execTestScript(`pw.expect(${i}).toBeLevel3xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "pass",
message: `Expected '${i}' to be 300-level status`,
}],
}),
])
}
})
test("assertion fails for non 300 series with no negation", async () => {
for (let i = 400; i < 500; i++) {
await expect(
execTestScript(`pw.expect(${i}).toBeLevel3xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: `Expected '${i}' to be 300-level status`,
}],
}),
])
}
})
test("give error if the expect value is not a number without negation", () => {
return expect(
execTestScript(`pw.expect("foo").toBeLevel3xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "error",
message: "Expected 300-level status but could not parse value 'foo'",
}],
})
])
})
test("assertion fails for 400 series with negation", async () => {
for (let i = 300; i < 400; i++) {
await expect(
execTestScript(`pw.expect(${i}).not.toBeLevel3xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: `Expected '${i}' to not be 300-level status`,
}],
}),
])
}
})
test("assertion passes for non 200 series with negation", async () => {
for (let i = 400; i < 500; i++) {
await expect(
execTestScript(`pw.expect(${i}).not.toBeLevel3xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "pass",
message: `Expected '${i}' to not be 300-level status`,
}],
}),
])
}
})
test("give error if the expect value is not a number with negation", () => {
return expect(
execTestScript(`pw.expect("foo").not.toBeLevel3xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "error",
message: "Expected 300-level status but could not parse value 'foo'",
}]
})
])
})
})
describe("toBeLevel4xx", () => {
test("assertion passes for 400 series with no negation", async () => {
for (let i = 400; i < 500; i++) {
await expect(
execTestScript(`pw.expect(${i}).toBeLevel4xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "pass",
message: `Expected '${i}' to be 400-level status`,
}],
}),
])
}
})
test("assertion fails for non 400 series with no negation", async () => {
for (let i = 500; i < 600; i++) {
await expect(
execTestScript(`pw.expect(${i}).toBeLevel4xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: `Expected '${i}' to be 400-level status`,
}],
}),
])
}
})
test("give error if the expected value is not a number without negation", () => {
return expect(
execTestScript(`pw.expect("foo").toBeLevel4xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "error",
message: "Expected 400-level status but could not parse value 'foo'",
}],
})
])
})
test("assertion fails for 400 series with negation", async () => {
for (let i = 400; i < 500; i++) {
await expect(
execTestScript(`pw.expect(${i}).not.toBeLevel4xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: `Expected '${i}' to not be 400-level status`,
}],
}),
])
}
})
test("assertion passes for non 400 series with negation", async () => {
for (let i = 500; i < 600; i++) {
await expect(
execTestScript(`pw.expect(${i}).not.toBeLevel4xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "pass",
message: `Expected '${i}' to not be 400-level status`,
}],
}),
])
}
})
test("give error if the expected value is not a number with negation", () => {
return expect(
execTestScript(`pw.expect("foo").not.toBeLevel4xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "error",
message: "Expected 400-level status but could not parse value 'foo'",
}],
})
])
})
})
describe("toBeLevel5xx", () => {
test("assertion passes for 500 series with no negation", async () => {
for (let i = 500; i < 600; i++) {
await expect(
execTestScript(`pw.expect(${i}).toBeLevel5xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "pass",
message: `Expected '${i}' to be 500-level status`,
}],
}),
])
}
})
test("assertion fails for non 500 series with no negation", async () => {
for (let i = 200; i < 500; i++) {
await expect(
execTestScript(`pw.expect(${i}).toBeLevel5xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: `Expected '${i}' to be 500-level status`,
}],
}),
])
}
})
test("give error if the expect value is not a number with no negation", () => {
return expect(
execTestScript(`pw.expect("foo").toBeLevel5xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "error",
message: "Expected 500-level status but could not parse value 'foo'",
}],
})
])
})
test("assertion fails for 500 series with negation", async () => {
for (let i = 500; i < 600; i++) {
await expect(
execTestScript(`pw.expect(${i}).not.toBeLevel5xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "fail",
message: `Expected '${i}' to not be 500-level status`,
}],
}),
])
}
})
test("assertion passes for non 500 series with negation", async () => {
for (let i = 200; i < 500; i++) {
await expect(
execTestScript(`pw.expect(${i}).not.toBeLevel5xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "pass",
message: `Expected '${i}' to not be 500-level status`,
}],
}),
])
}
})
test("give error if the expect value is not a number with negation", () => {
return expect(
execTestScript(`pw.expect("foo").not.toBeLevel5xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [{
status: "error",
message: "Expected 500-level status but could not parse value 'foo'",
}],
})
])
})
})

View File

@@ -0,0 +1,157 @@
import { execTestScript, TestResponse } from "../../../test-runner"
const fakeResponse: TestResponse = {
status: 200,
body: "hoi",
headers: []
}
describe("toBeType", () => {
test("asserts true for valid type expectations with no negation", () => {
return expect(
execTestScript(
`
pw.expect(2).toBeType("number")
pw.expect("2").toBeType("string")
pw.expect(true).toBeType("boolean")
pw.expect({}).toBeType("object")
pw.expect(undefined).toBeType("undefined")
`, fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "pass", message: `Expected '2' to be type 'number'` },
{ status: "pass", message: `Expected '2' to be type 'string'` },
{ status: "pass", message: `Expected 'true' to be type 'boolean'` },
{ status: "pass", message: `Expected '[object Object]' to be type 'object'` },
{ status: "pass", message: `Expected 'undefined' to be type 'undefined'` },
],
}),
])
})
test("asserts false for invalid type expectations with no negation", () => {
return expect(
execTestScript(
`
pw.expect(2).toBeType("string")
pw.expect("2").toBeType("number")
pw.expect(true).toBeType("string")
pw.expect({}).toBeType("number")
pw.expect(undefined).toBeType("number")
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "fail", message: `Expected '2' to be type 'string'`},
{ status: "fail", message: `Expected '2' to be type 'number'`},
{ status: "fail", message: `Expected 'true' to be type 'string'`},
{ status: "fail", message: `Expected '[object Object]' to be type 'number'`},
{ status: "fail", message: `Expected 'undefined' to be type 'number'`},
],
}),
])
})
test("asserts false for valid type expectations with negation", () => {
return expect(
execTestScript(
`
pw.expect(2).not.toBeType("number")
pw.expect("2").not.toBeType("string")
pw.expect(true).not.toBeType("boolean")
pw.expect({}).not.toBeType("object")
pw.expect(undefined).not.toBeType("undefined")
`, fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "fail", message: `Expected '2' to not be type 'number'` },
{ status: "fail", message: `Expected '2' to not be type 'string'` },
{ status: "fail", message: `Expected 'true' to not be type 'boolean'` },
{ status: "fail", message: `Expected '[object Object]' to not be type 'object'` },
{ status: "fail", message: `Expected 'undefined' to not be type 'undefined'` },
],
}),
])
})
test("asserts true for invalid type expectations with negation", () => {
return expect(
execTestScript(
`
pw.expect(2).not.toBeType("string")
pw.expect("2").not.toBeType("number")
pw.expect(true).not.toBeType("string")
pw.expect({}).not.toBeType("number")
pw.expect(undefined).not.toBeType("number")
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "pass", message: `Expected '2' to not be type 'string'` },
{ status: "pass", message: `Expected '2' to not be type 'number'` },
{ status: "pass", message: `Expected 'true' to not be type 'string'` },
{ status: "pass", message: `Expected '[object Object]' to not be type 'number'` },
{ status: "pass", message: `Expected 'undefined' to not be type 'number'` },
],
}),
])
})
test("gives error for invalid type names without negation", () => {
return expect(
execTestScript(
`
pw.expect(2).toBeType("foo")
pw.expect("2").toBeType("bar")
pw.expect(true).toBeType("baz")
pw.expect({}).toBeType("qux")
pw.expect(undefined).toBeType("quux")
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
]
})
])
})
test("gives error for invalid type names with negation", () => {
return expect(
execTestScript(
`
pw.expect(2).not.toBeType("foo")
pw.expect("2").not.toBeType("bar")
pw.expect(true).not.toBeType("baz")
pw.expect({}).not.toBeType("qux")
pw.expect(undefined).not.toBeType("quux")
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
{ status: "error", message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"` },
]
})
])
})
})

View File

@@ -0,0 +1,157 @@
import { execTestScript, TestResponse } from "../../../test-runner"
const fakeResponse: TestResponse = {
status: 200,
body: "hoi",
headers: []
}
describe("toHaveLength", () => {
test("asserts true for valid lengths with no negation", () => {
return expect(
execTestScript(
`
pw.expect([1, 2, 3, 4]).toHaveLength(4)
pw.expect([]).toHaveLength(0)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "pass", message: "Expected the array to be of length '4'" },
{ status: "pass", message: "Expected the array to be of length '0'" },
],
}),
])
})
test("asserts false for invalid lengths with no negation", () => {
return expect(
execTestScript(
`
pw.expect([]).toHaveLength(4)
pw.expect([1, 2, 3, 4]).toHaveLength(0)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "fail", message: "Expected the array to be of length '4'" },
{ status: "fail", message: "Expected the array to be of length '0'" },
],
}),
])
})
test("asserts false for valid lengths with negation", () => {
return expect(
execTestScript(
`
pw.expect([1, 2, 3, 4]).not.toHaveLength(4)
pw.expect([]).not.toHaveLength(0)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "fail", message: "Expected the array to not be of length '4'" },
{ status: "fail", message: "Expected the array to not be of length '0'" },
],
}),
])
})
test("asserts true for invalid lengths with negation", () => {
return expect(
execTestScript(
`
pw.expect([]).not.toHaveLength(4)
pw.expect([1, 2, 3, 4]).not.toHaveLength(0)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "pass", message: "Expected the array to not be of length '4'" },
{ status: "pass", message: "Expected the array to not be of length '0'" },
],
}),
])
})
test("gives error if not called on an array or a string with no negation", () => {
return expect(
execTestScript(
`
pw.expect(5).toHaveLength(0)
pw.expect(true).toHaveLength(0)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "error", message: "Expected toHaveLength to be called for an array or string" },
{ status: "error", message: "Expected toHaveLength to be called for an array or string" },
]
})
])
})
test("gives error if not called on an array or a string with negation", () => {
return expect(
execTestScript(
`
pw.expect(5).not.toHaveLength(0)
pw.expect(true).not.toHaveLength(0)
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "error", message: "Expected toHaveLength to be called for an array or string" },
{ status: "error", message: "Expected toHaveLength to be called for an array or string" },
]
})
])
})
test("gives an error if toHaveLength parameter is not a number without negation", () => {
return expect(
execTestScript(
`
pw.expect([1, 2, 3, 4]).toHaveLength("a")
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "error", message: "Argument for toHaveLength should be a number" },
],
})
])
})
test("gives an error if toHaveLength parameter is not a number with negation", () => {
return expect(
execTestScript(
`
pw.expect([1, 2, 3, 4]).not.toHaveLength("a")
`,
fakeResponse
)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{ status: "error", message: "Argument for toHaveLength should be a number" },
],
})
])
})
})

View File

@@ -0,0 +1,59 @@
import { execTestScript, TestResponse } from "../../test-runner"
const fakeResponse: TestResponse = {
status: 200,
body: "hoi",
headers: []
}
describe("execTestScript function behavior", () => {
test("returns a resolved promise for a valid test scripts with all green", () => {
return expect(
execTestScript(
`
pw.test("Arithmetic operations", () => {
const size = 500 + 500;
pw.expect(size).toBe(1000);
pw.expect(size - 500).toBe(500);
pw.expect(size * 4).toBe(4000);
pw.expect(size / 4).toBe(250);
});
`,
fakeResponse
)()
).resolves.toBeRight();
})
test("resolves for tests with failed expectations", () => {
return expect(
execTestScript(
`
pw.test("Arithmetic operations", () => {
const size = 500 + 500;
pw.expect(size).toBe(1000);
pw.expect(size - 500).not.toBe(500);
pw.expect(size * 4).toBe(4000);
pw.expect(size / 4).not.toBe(250);
});
`, fakeResponse
)()
).resolves.toBeRight()
})
// TODO: We need a more concrete behavior for this
test("rejects for invalid syntax on tests", () => {
return expect(
execTestScript(
`
pw.test("Arithmetic operations", () => {
const size = 500 + 500;
pw.expect(size).
pw.expect(size - 500).not.toBe(500);
pw.expect(size * 4).toBe(4000);
pw.expect(size / 4).not.toBe(250);
});
`, fakeResponse
)()
).resolves.toBeLeft()
})
})

View File

@@ -0,0 +1,57 @@
import { match } from "fp-ts/lib/Either"
import { pipe } from "fp-ts/lib/function"
import * as QuickJS from "quickjs-emscripten"
import { marshalObjectToVM } from "../utils"
let vm: QuickJS.QuickJSVm
beforeAll(async () => {
const qjs = await QuickJS.getQuickJS()
vm = qjs.createVm()
})
afterAll(() => {
vm.dispose()
})
describe("marshalObjectToVM", () => {
test("successfully marshals simple object into the vm", () => {
const testObj = {
a: 1
}
const objVMHandle: QuickJS.QuickJSHandle | null = pipe(
marshalObjectToVM(vm, testObj),
match(
(e) => null,
(result) => result
)
)
expect(objVMHandle).not.toBeNull()
expect(vm.dump(objVMHandle!)).toEqual(testObj)
objVMHandle!.dispose()
})
test("fails marshalling cyclic object into vm", () => {
const testObj = {
a: 1,
b: null as any
}
testObj.b = testObj
const objVMHandle: QuickJS.QuickJSHandle | null = pipe(
marshalObjectToVM(vm, testObj),
match(
(e) => null,
(result) => result
)
)
expect(objVMHandle).toBeNull()
})
})

View File

@@ -0,0 +1,55 @@
import { runTestScript } from "./index"
import { TestResponse } from "./test-runner"
const dummyResponse: TestResponse = {
status: 200,
body: "hoi",
headers: []
};
// eslint-disable-next-line prettier/prettier
(async () => {
console.dir(
await runTestScript(
`
pw.test("Arithmetic operations and toBe", () => {
const size = 500 + 500;
pw.expect(size).toBe(1000);
pw.expect(size - 500).toBe(500);
pw.expect(size * 4).toBe(4000);
pw.expect(size / 4).toBe(250);
});
pw.test("toBeLevelxxx", () => {
pw.expect(200).toBeLevel2xx();
pw.expect(204).toBeLevel2xx();
pw.expect(300).not.toBeLevel2xx();
pw.expect(300).toBeLevel3xx();
pw.expect(304).toBeLevel3xx();
pw.expect(204).not.toBeLevel3xx();
pw.expect(401).toBeLevel4xx();
pw.expect(404).toBeLevel4xx();
pw.expect(204).not.toBeLevel4xx();
pw.expect(501).toBeLevel5xx();
pw.expect(504).toBeLevel5xx();
pw.expect(204).not.toBeLevel5xx();
});
pw.test("toBeType", () => {
pw.expect("hello").toBeType("string");
pw.expect(10).toBeType("number");
pw.expect(true).toBeType("boolean");
pw.expect("deffonotanumber").not.toBeType("number");
});
pw.test("toHaveLength", () => {
const arr = [1, 2, 3];
pw.expect(arr).toHaveLength(3);
pw.expect(arr).not.toHaveLength(4);
});
`,
dummyResponse
),
{
depth: 100,
}
)
})()

View File

@@ -0,0 +1 @@
import '@relmify/jest-fp-ts';

View File

@@ -0,0 +1,26 @@
import { pipe } from "fp-ts/lib/function"
import { chain, right } from "fp-ts/lib/TaskEither"
import { execPreRequestScript } from "./preRequest"
import { execTestScript, TestResponse, TestDescriptor as _TestDescriptor } from "./test-runner"
export type TestDescriptor = _TestDescriptor
/**
* Executes a given test script on the test-runner sandbox
* @param testScript The string of the script to run
* @returns A TaskEither with an error message or a TestDescriptor with the final status
*/
export const runTestScript = (
testScript: string,
response: TestResponse
) => pipe(
execTestScript(testScript, response),
chain((results) => right(results[0])) // execTestScript returns an array of descriptors with a single element (extract that)
)
/**
* Executes a given pre-request script on the sandbox
* @param preRequestScript The script to run
* @param env The envirionment variables active
* @returns A TaskEither with an error message or an array of the final environments with the all the script values applied
*/
export const runPreRequestScript = execPreRequestScript

View File

@@ -0,0 +1,77 @@
import { pipe } from "fp-ts/lib/function";
import { chain, TaskEither, tryCatch, right, left } from "fp-ts/lib/TaskEither";
import * as qjs from "quickjs-emscripten";
import clone from "lodash/clone";
type EnvEntry = {
key: string;
value: string;
};
export const execPreRequestScript = (
preRequestScript: string,
env: EnvEntry[]
): TaskEither<string, EnvEntry[]> => pipe(
tryCatch(
async () => await qjs.getQuickJS(),
(reason) => `QuickJS initialization failed: ${reason}`
),
chain(
(QuickJS) => {
const finalEnv = clone(env)
const vm = QuickJS.createVm()
const pwHandle = vm.newObject()
const envHandle = vm.newObject()
const envSetFuncHandle = vm.newFunction("set", (keyHandle, valueHandle) => {
const key = vm.dump(keyHandle)
const value = vm.dump(valueHandle)
if (typeof key !== "string") return {
error: vm.newString("Expected key to be a string")
}
if (typeof value !== "string") return {
error: vm.newString("Expected value to be a string")
}
const keyIndex = finalEnv.findIndex((env) => env.key === key)
if (keyIndex === -1) {
finalEnv.push({ key, value })
} else {
finalEnv[keyIndex] = { key, value }
}
return {
value: vm.undefined
}
})
vm.setProp(envHandle, "set", envSetFuncHandle)
envSetFuncHandle.dispose()
vm.setProp(pwHandle, "env", envHandle)
envHandle.dispose()
vm.setProp(vm.global, "pw", pwHandle)
pwHandle.dispose()
const evalRes = vm.evalCode(preRequestScript)
if (evalRes.error) {
const errorData = vm.dump(evalRes.error)
evalRes.error.dispose()
return left(`Script evaluation failed: ${errorData}`)
}
vm.dispose()
return right(finalEnv)
}
)
)

View File

@@ -0,0 +1,367 @@
import { isLeft } from "fp-ts/lib/Either"
import { pipe } from "fp-ts/lib/function"
import { TaskEither, tryCatch, chain, right, left } from "fp-ts/lib/TaskEither"
import * as qjs from "quickjs-emscripten"
import { marshalObjectToVM } from "./utils"
/**
* The response object structure exposed to the test script
*/
export type TestResponse = {
/** Status Code of the response */
status: number,
/** List of headers returned */
headers: { key: string, value: string }[],
/**
* Body of the response, this will be the JSON object if it is a JSON content type, else body string
*/
body: string | object
}
/**
* The result of an expectation statement
*/
type ExpectResult =
| { status: "pass" | "fail" | "error", message: string } // The expectation failed (fail) or errored (error)
/**
* An object defining the result of the execution of a
* test block
*/
export type TestDescriptor = {
/**
* The name of the test block
*/
descriptor: string
/**
* Expectation results of the test block
*/
expectResults: ExpectResult[]
/**
* Children test blocks (test blocks inside the test block)
*/
children: TestDescriptor[]
}
/**
* Creates an Expectation object for use inside the sandbox
* @param vm The QuickJS sandbox VM instance
* @param expectVal The expecting value of the expectation
* @param negated Whether the expectation is negated (negative)
* @param currTestStack The current state of the test execution stack
* @returns Handle to the expectation object in VM
*/
function createExpectation(
vm: qjs.QuickJSVm,
expectVal: any,
negated: boolean,
currTestStack: TestDescriptor[]
): qjs.QuickJSHandle {
const resultHandle = vm.newObject()
const toBeFnHandle = vm.newFunction("toBe", (expectedValHandle) => {
const expectedVal = vm.dump(expectedValHandle)
let assertion = expectVal === expectedVal
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${negated ? " not" : ""} be '${expectedVal}'`
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected '${expectVal}' to${negated ? " not" : ""} be '${expectedVal}'`,
})
}
return { value: vm.undefined }
})
const toBeLevel2xxHandle = vm.newFunction("toBeLevel2xx", () => {
// Check if the expected value is a number, else it is an error
if (typeof expectVal === "number" && !Number.isNaN(expectVal)) {
let assertion = expectVal >= 200 && expectVal <= 299
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${negated ? " not" : ""} be 200-level status`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message:
`Expected '${expectVal}' to${negated ? " not" : ""} be 200-level status`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected 200-level status but could not parse value '${expectVal}'`,
})
}
return { value: vm.undefined }
})
const toBeLevel3xxHandle = vm.newFunction("toBeLevel3xx", () => {
// Check if the expected value is a number, else it is an error
if (typeof expectVal === "number" && !Number.isNaN(expectVal)) {
let assertion = expectVal >= 300 && expectVal <= 399
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${negated ? " not" : ""} be 300-level status`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message:
`Expected '${expectVal}' to${negated ? " not" : ""} be 300-level status`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected 300-level status but could not parse value '${expectVal}'`,
})
}
return { value: vm.undefined }
})
const toBeLevel4xxHandle = vm.newFunction("toBeLevel4xx", () => {
// Check if the expected value is a number, else it is an error
if (typeof expectVal === "number" && !Number.isNaN(expectVal)) {
let assertion = expectVal >= 400 && expectVal <= 499
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${negated ? " not" : ""} be 400-level status`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message:
`Expected '${expectVal}' to${negated ? " not" : ""} be 400-level status`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected 400-level status but could not parse value '${expectVal}'`,
})
}
return { value: vm.undefined }
})
const toBeLevel5xxHandle = vm.newFunction("toBeLevel5xx", () => {
// Check if the expected value is a number, else it is an error
if (typeof expectVal === "number" && !Number.isNaN(expectVal)) {
let assertion = expectVal >= 500 && expectVal <= 599
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${negated ? " not" : ""} be 500-level status`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected '${expectVal}' to${negated ? " not" : ""} be 500-level status`
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected 500-level status but could not parse value '${expectVal}'`,
})
}
return { value: vm.undefined }
})
const toBeTypeHandle = vm.newFunction("toBeType", (expectedValHandle) => {
const expectedType = vm.dump(expectedValHandle)
// Check if the expectation param is a valid type name string, else error
if (["string", "boolean", "number", "object", "undefined", "bigint", "symbol", "function"].includes(expectedType)) {
let assertion = typeof expectVal === expectedType
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected '${expectVal}' to${negated ? " not" : ""} be type '${expectedType}'`
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected '${expectVal}' to${negated ? " not" : ""} be type '${expectedType}'`,
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Argument for toBeType should be "string", "boolean", "number", "object", "undefined", "bigint", "symbol" or "function"`
})
}
return { value: vm.undefined }
})
const toHaveLengthHandle = vm.newFunction(
"toHaveLength",
(expectedValHandle) => {
const expectedLength = vm.dump(expectedValHandle)
if (!(Array.isArray(expectVal) || typeof expectVal === "string")) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Expected toHaveLength to be called for an array or string`,
})
return { value: vm.undefined }
}
// Check if the parameter is a number, else error
if (typeof expectedLength === "number" && !Number.isNaN(expectedLength)) {
let assertion = (expectVal as any[]).length === expectedLength
if (negated) assertion = !assertion
if (assertion) {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "pass",
message: `Expected the array to${negated ? " not" : ""} be of length '${expectedLength}'`,
})
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "fail",
message: `Expected the array to${negated ? " not" : ""} be of length '${expectedLength}'`
})
}
} else {
currTestStack[currTestStack.length - 1].expectResults.push({
status: "error",
message: `Argument for toHaveLength should be a number`
})
}
return { value: vm.undefined }
}
)
vm.setProp(resultHandle, "toBe", toBeFnHandle)
vm.setProp(resultHandle, "toBeLevel2xx", toBeLevel2xxHandle)
vm.setProp(resultHandle, "toBeLevel3xx", toBeLevel3xxHandle)
vm.setProp(resultHandle, "toBeLevel4xx", toBeLevel4xxHandle)
vm.setProp(resultHandle, "toBeLevel5xx", toBeLevel5xxHandle)
vm.setProp(resultHandle, "toBeType", toBeTypeHandle)
vm.setProp(resultHandle, "toHaveLength", toHaveLengthHandle)
vm.defineProp(resultHandle, "not", {
get: () => {
return createExpectation(vm, expectVal, !negated, currTestStack)
},
})
toBeFnHandle.dispose()
toBeLevel2xxHandle.dispose()
toBeLevel3xxHandle.dispose()
toBeLevel4xxHandle.dispose()
toBeLevel5xxHandle.dispose()
toBeTypeHandle.dispose()
toHaveLengthHandle.dispose()
return resultHandle
}
export const execTestScript = (
testScript: string,
response: TestResponse
): TaskEither<string, TestDescriptor[]> => pipe(
tryCatch(
async () => await qjs.getQuickJS(),
(reason) => `QuickJS initialization failed: ${reason}`
),
chain(
// TODO: Make this more functional ?
(QuickJS) => {
const vm = QuickJS.createVm()
const pwHandle = vm.newObject()
const testRunStack: TestDescriptor[] = [
{ descriptor: "root", expectResults: [], children: [] },
]
const testFuncHandle = vm.newFunction(
"test",
(descriptorHandle, testFuncHandle) => {
const descriptor = vm.getString(descriptorHandle)
testRunStack.push({
descriptor,
expectResults: [],
children: [],
})
const result = vm.unwrapResult(vm.callFunction(testFuncHandle, vm.null))
result.dispose()
const child = testRunStack.pop() as TestDescriptor
testRunStack[testRunStack.length - 1].children.push(child)
}
)
const expectFnHandle = vm.newFunction("expect", (expectValueHandle) => {
const expectVal = vm.dump(expectValueHandle)
return {
value: createExpectation(vm, expectVal, false, testRunStack),
}
})
// Marshal response object
const responseObjHandle = marshalObjectToVM(vm, response)
if (isLeft(responseObjHandle)) return left(`Response marshalling failed: ${responseObjHandle.left}`)
vm.setProp(pwHandle, "response", responseObjHandle.right)
responseObjHandle.right.dispose()
vm.setProp(pwHandle, "expect", expectFnHandle)
expectFnHandle.dispose()
vm.setProp(pwHandle, "test", testFuncHandle)
testFuncHandle.dispose()
vm.setProp(vm.global, "pw", pwHandle)
pwHandle.dispose()
const evalRes = vm.evalCode(testScript)
if (evalRes.error) {
const errorData = vm.dump(evalRes.error)
evalRes.error.dispose()
return left(`Script evaluation failed: ${errorData}`)
}
vm.dispose()
return right(testRunStack)
}
)
)

View File

@@ -0,0 +1,32 @@
import { Either, left, right } from "fp-ts/lib/Either";
import * as QuickJS from "quickjs-emscripten";
export function marshalObjectToVM(vm: QuickJS.QuickJSVm, obj: object): Either<string, QuickJS.QuickJSHandle> {
let jsonString
try {
jsonString = JSON.stringify(obj)
} catch (e) {
return left("Marshaling stringification failed")
}
const vmStringHandle = vm.newString(jsonString)
const jsonHandle = vm.getProp(vm.global, "JSON")
const parseFuncHandle = vm.getProp(jsonHandle, "parse")
const parseResultHandle = vm.callFunction(parseFuncHandle, vm.undefined, vmStringHandle)
if (parseResultHandle.error) {
parseResultHandle.error.dispose()
return left("Marshaling failed")
}
const resultHandle = vm.unwrapResult(parseResultHandle)
vmStringHandle.dispose()
parseFuncHandle.dispose()
jsonHandle.dispose()
return right(resultHandle)
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES6",
"module": "ESNext",
"moduleResolution": "Node",
"lib": ["ESNext", "ESNext.AsyncIterable", "DOM"],
"esModuleInterop": true,
"strict": true,
"paths": {
"~/*": ["./src/*"],
"@/*": ["./src/*"]
},
"types": [
"@types/node",
"@types/jest",
"@relmify/jest-fp-ts"
],
"outDir": "./lib/",
"rootDir": "./src/",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["./src", "./src/global.d.ts"],
"exclude": ["node_modules", "./src/__tests__", "./src/demo.ts"]
}