feat: migrate to a unified scripting system based on faraday-cage (#5090)

Co-authored-by: curiouscorrelation <curiouscorrelation@gmail.com>
This commit is contained in:
James George 2025-05-23 22:54:27 +05:30 committed by GitHub
parent 01d96fa577
commit 656a15a983
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2173 additions and 710 deletions

View file

@ -19,54 +19,74 @@ hopp [options or commands] arguments
## **Command Descriptions:**
1. #### **`hopp -v` / `hopp --ver`**
1. #### **`hopp -v` / `hopp --ver`**
- Prints out the current version of the Hoppscotch CLI
- Prints out the current version of the Hoppscotch CLI
2. #### **`hopp -h` / `hopp --help`**
2. #### **`hopp -h` / `hopp --help`**
- Displays the help text
- Displays the help text
3. #### **`hopp test [options] <file_path>`**
3. #### **`hopp test [options] <file_path_or_id>`**
- Interactive CLI to accept Hoppscotch collection JSON path
- Parses the collection JSON and executes each requests
- Executes pre-request script.
- Outputs the response of each request.
- Executes and outputs test-script response.
- Interactive CLI to accept Hoppscotch collection JSON path
- Parses the collection JSON and executes each requests
- Executes pre-request script.
- Outputs the response of each request.
- Executes and outputs test-script response.
#### Options:
#### Options:
##### `-e <file_path>` / `--env <file_path>`
##### `-e, --env <file_path_or_id> `
- Accepts path to env.json with contents in below format:
- Accepts path to env.json with contents in below format:
```json
{
"ENV1": "value1",
"ENV2": "value2"
}
```
```json
{
"ENV1": "value1",
"ENV2": "value2"
}
```
- You can now access those variables using `pw.env.get('<var_name>')`
- You can now access those variables using `pw.env.get('<var_name>')`
Taking the above example, `pw.env.get("ENV1")` will return `"value1"`
Taking the above example, `pw.env.get("ENV1")` will return `"value1"`
##### `--iteration-count <no_of_iterations>`
#### `-d, --delay <delay_in_ms>`
- Accepts the number of iterations to run the collection
- Used to defer the execution of requests in a collection.
##### `--iteration-data <file_path>`
#### `--token <access_token>`
- Accepts the path to a CSV file with contents in the below format:
- Expects a personal access token to be passed for establishing connection with your Hoppscotch account.
```text
key1,key2,key3
value1,value2,value3
value4,value5,value6
```
#### `--server <server_url>`
For every iteration the values will be replaced with the respective keys in the environment. For iteration 1 the value1,value2,value3 will be replaced and for iteration 2 value4,value5,value6 will be replaced and so on.
- URL of your self-hosted instance, if your collections are on a self-hosted instance.
#### `--reporter-junit [path]`
- Expects a file path to store the JUnit Report.
##### `--iteration-count <no_of_iterations>`
- Accepts the number of iterations to run the collection
##### `--iteration-data <file_path>`
- Accepts the path to a CSV file with contents in the below format:
```text
key1,key2,key3
value1,value2,value3
value4,value5,value6
```
For every iteration the values will be replaced with the respective keys in the environment. For iteration 1 the value1,value2,value3 will be replaced and for iteration 2 value4,value5,value6 will be replaced and so on.
#### `--legacy-sandbox`
- Opt out from the experimental scripting sandbox.
## Install

View file

@ -1,6 +1,6 @@
{
"name": "@hoppscotch/cli",
"version": "0.21.0",
"version": "0.22.0",
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io",
"type": "module",

View file

@ -162,7 +162,7 @@ exports[`hopp test [options] <file_path_or_id> > Test\`hopp test <file_path_or_i
<testsuite name="test-junit-report-export/request-level-errors/test-script-reference-error" time="time" timestamp="timestamp" tests="0" failures="0" errors="0">
<system-err><![CDATA[
REQUEST_ERROR - TypeError: Invalid URL
TEST_SCRIPT_ERROR - Script execution failed: ReferenceError: status is not defined]]></system-err>
TEST_SCRIPT_ERROR - Script execution failed: ReferenceError: 'status' is not defined]]></system-err>
</testsuite>
<testsuite name="test-junit-report-export/request-level-errors/non-existent-env-var" time="time" timestamp="timestamp" tests="22" failures="0" errors="22">
<system-err><![CDATA[
@ -401,7 +401,7 @@ exports[`hopp test [options] <file_path_or_id> > Test\`hopp test <file_path_or_i
<testsuite name="test-junit-report-export/request-level-errors/test-script-reference-error" time="time" timestamp="timestamp" tests="0" failures="0" errors="0">
<system-err><![CDATA[
REQUEST_ERROR - TypeError: Invalid URL
TEST_SCRIPT_ERROR - Script execution failed: ReferenceError: status is not defined]]></system-err>
TEST_SCRIPT_ERROR - Script execution failed: ReferenceError: 'status' is not defined]]></system-err>
</testsuite>
<testsuite name="test-junit-report-export/request-level-errors/non-existent-env-var" time="time" timestamp="timestamp" tests="22" failures="0" errors="22">
<system-err><![CDATA[

View file

@ -20,9 +20,7 @@ describe("parseCollectionData", () => {
test("Invalid HoppCollection.", () => {
return expect(
parseCollectionData(
"./src/__tests__/samples/malformed-collection2.json"
)
parseCollectionData("./src/__tests__/samples/malformed-collection2.json")
).rejects.toMatchObject(<HoppCLIError>{
code: "MALFORMED_COLLECTION",
});

View file

@ -20,8 +20,14 @@ import { parseCollectionData } from "../utils/mutators";
export const test = (pathOrId: string, options: TestCmdOptions) => async () => {
try {
const { delay, env, iterationCount, iterationData, reporterJunit } =
options;
const {
delay,
env,
iterationCount,
iterationData,
reporterJunit,
legacySandbox,
} = options;
if (
iterationCount !== undefined &&
@ -86,12 +92,15 @@ export const test = (pathOrId: string, options: TestCmdOptions) => async () => {
.filter((item) => item.length > 0);
}
const resolvedLegacySandbox = Boolean(legacySandbox);
const report = await collectionsRunner({
collections,
envs,
delay: resolvedDelay,
iterationData: transformedIterationData,
iterationCount,
legacySandbox: resolvedLegacySandbox,
});
const hasSucceeded = collectionsRunnerResult(report, reporterJunit);

View file

@ -78,6 +78,7 @@ program
"--iteration-data <file_path>",
"path to a CSV file for data-driven testing"
)
.option("--legacy-sandbox", "Opt out from the experimental scripting sandbox")
.allowExcessArguments(false)
.allowUnknownOption(false)
.description("running hoppscotch collection.json file")

View file

@ -43,6 +43,7 @@ export interface TestScriptParams {
testScript: string;
response: TestResponse;
envs: HoppEnvs;
legacySandbox: boolean;
}
/**

View file

@ -1,14 +1,14 @@
import { error } from "../../types/errors";
export function parseDelayOption(delay: string): number {
const maybeInt = Number.parseInt(delay)
const maybeInt = Number.parseInt(delay);
if(!Number.isNaN(maybeInt)) {
return maybeInt
if (!Number.isNaN(maybeInt)) {
return maybeInt;
} else {
throw error({
code: "INVALID_ARGUMENT",
data: "Expected '-d, --delay' value to be number",
})
});
}
}

View file

@ -7,6 +7,7 @@ export type CollectionRunnerParam = {
delay?: number;
iterationData?: IterationDataItem[][];
iterationCount?: number;
legacySandbox: boolean;
};
export type HoppCollectionFileExt = "json";

View file

@ -6,6 +6,7 @@ export type TestCmdOptions = {
reporterJunit?: string;
iterationCount?: number;
iterationData?: string;
legacySandbox?: boolean;
};
// Consumed in the collection `file_path_or_id` argument action handler

View file

@ -37,4 +37,5 @@ export type ProcessRequestParams = {
envs: HoppEnvs;
path: string;
delay: number;
legacySandbox: boolean;
};

View file

@ -47,7 +47,14 @@ const { WARN, FAIL, INFO } = exceptionColors;
export const collectionsRunner = async (
param: CollectionRunnerParam
): Promise<RequestReport[]> => {
const { collections, envs, delay, iterationCount, iterationData } = param;
const {
collections,
envs,
delay,
iterationCount,
iterationData,
legacySandbox,
} = param;
const resolvedDelay = delay ?? 0;
@ -87,7 +94,8 @@ export const collectionsRunner = async (
path,
envs,
resolvedDelay,
requestsReport
requestsReport,
legacySandbox
);
}
}
@ -100,7 +108,8 @@ const processCollection = async (
path: string,
envs: HoppEnvs,
delay: number,
requestsReport: RequestReport[]
requestsReport: RequestReport[],
legacySandbox?: boolean
) => {
// Process each request in the collection
for (const request of collection.requests) {
@ -111,6 +120,7 @@ const processCollection = async (
request: _request,
envs,
delay,
legacySandbox,
};
// Request processing initiated message.
@ -156,7 +166,8 @@ const processCollection = async (
`${path}/${updatedFolder.name}`,
envs,
delay,
requestsReport
requestsReport,
legacySandbox
);
}
};

View file

@ -120,7 +120,9 @@ export const printErrorsReport = (
errorsReport: HoppCLIError[]
) => {
if (errorsReport.length > 0) {
const REPORTED_ERRORS_TITLE = FAIL(`\n${chalk.bold(path)} reported errors:`);
const REPORTED_ERRORS_TITLE = FAIL(
`\n${chalk.bold(path)} reported errors:`
);
group(REPORTED_ERRORS_TITLE);
for (const errorReport of errorsReport) {

View file

@ -28,7 +28,7 @@ export const arrayFlatMap =
export const tupleToRecord = <
KeyType extends string | number | symbol,
ValueType
ValueType,
>(
tuples: [KeyType, ValueType][]
): Record<KeyType, ValueType> =>

View file

@ -44,15 +44,18 @@ import { calculateHawkHeader } from "@hoppscotch/data";
*/
export const preRequestScriptRunner = (
request: HoppRESTRequest,
envs: HoppEnvs
envs: HoppEnvs,
legacySandbox: boolean
): TE.TaskEither<
HoppCLIError,
{ effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs }
> =>
pipe(
> => {
const experimentalScriptingSandbox = !legacySandbox;
return pipe(
TE.of(request),
TE.chain(({ preRequestScript }) =>
runPreRequestScript(preRequestScript, envs)
runPreRequestScript(preRequestScript, envs, experimentalScriptingSandbox)
),
TE.map(
({ selected, global }) =>
@ -77,6 +80,7 @@ export const preRequestScriptRunner = (
})
)
);
};
/**
* Outputs an executable request format with environment variables applied

View file

@ -202,7 +202,7 @@ export const processRequest =
params: ProcessRequestParams
): T.Task<{ envs: HoppEnvs; report: RequestReport }> =>
async () => {
const { envs, path, request, delay } = params;
const { envs, path, request, delay, legacySandbox } = params;
// Initialising updatedEnvs with given parameter envs, will eventually get updated.
const result = {
@ -235,7 +235,8 @@ export const processRequest =
// Executing pre-request-script
const preRequestRes = await preRequestScriptRunner(
request,
processedEnvs
processedEnvs,
legacySandbox
)();
if (E.isLeft(preRequestRes)) {
printPreRequestRunner.fail();
@ -287,7 +288,8 @@ export const processRequest =
const testScriptParams = getTestScriptParams(
_requestRunnerRes,
request,
updatedEnvs
updatedEnvs,
legacySandbox
);
// Executing test-runner.

View file

@ -37,9 +37,15 @@ export const testRunner = (
TE.bind("test_response", () =>
pipe(
TE.of(testScriptData),
TE.chain(({ testScript, response, envs }) =>
runTestScript(testScript, envs, response)
)
TE.chain(({ testScript, response, envs, legacySandbox }) => {
const experimentalScriptingSandbox = !legacySandbox;
return runTestScript(
testScript,
envs,
response,
experimentalScriptingSandbox
);
})
)
),
@ -137,7 +143,8 @@ export const testDescriptorParser = (
export const getTestScriptParams = (
reqRunnerRes: RequestRunnerResponse,
request: HoppRESTRequest,
envs: HoppEnvs
envs: HoppEnvs,
legacySandbox: boolean
) => {
const testScriptParams: TestScriptParams = {
testScript: request.testScript,
@ -146,7 +153,8 @@ export const getTestScriptParams = (
status: reqRunnerRes.status,
headers: reqRunnerRes.headers,
},
envs: envs,
envs,
legacySandbox,
};
return testScriptParams;
};

View file

@ -956,6 +956,7 @@
"ai_request_naming_style_pascal_case": "Pascal Case ( PascalCase )",
"ai_request_naming_style_custom": "Custom",
"ai_request_naming_style_custom_placeholder": "Enter your custom naming style template...",
"experimental_scripting_sandbox": "Experimental scripting sandbox",
"sync": "Synchronise",
"sync_collections": "Collections",
"sync_description": "These settings are synced to cloud.",
@ -1851,5 +1852,9 @@
"update_billing_cycle": "Are you sure you want to update the billing cycle to {newBillingCycle}?"
},
"cancel_subscription": "Cancel subscription"
},
"app_console": {
"entries": "Console entries",
"no_entries": "No entries"
}
}

View file

@ -34,12 +34,12 @@
"@codemirror/search": "6.5.6",
"@codemirror/state": "6.4.1",
"@codemirror/view": "6.25.1",
"@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload",
"@hoppscotch/codemirror-lang-graphql": "workspace:^",
"@hoppscotch/data": "workspace:^",
"@hoppscotch/httpsnippet": "3.0.7",
"@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/kernel": "workspace:^",
"@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload",
"@hoppscotch/ui": "0.2.2",
"@hoppscotch/vue-toasted": "0.1.0",
"@lezer/highlight": "1.2.0",
@ -47,8 +47,8 @@
"@scure/base": "1.1.9",
"@shopify/lang-jsonc": "1.0.0",
"@tauri-apps/plugin-store": "2.2.0",
"@types/markdown-it": "14.1.2",
"@types/hawk": "9.0.6",
"@types/markdown-it": "14.1.2",
"@unhead/vue": "1.11.10",
"@urql/core": "5.0.6",
"@urql/devtools": "2.0.3",
@ -105,6 +105,7 @@
"verzod": "0.2.4",
"vue": "3.5.12",
"vue-i18n": "10.0.4",
"vue-json-pretty": "2.4.0",
"vue-pdf-embed": "2.1.0",
"vue-router": "4.4.5",
"vue-tippy": "6.5.0",

View file

@ -71,6 +71,9 @@ declare module 'vue' {
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
ConsoleItem: typeof import('./components/console/Item.vue')['default']
ConsolePanel: typeof import('./components/console/Panel.vue')['default']
ConsoleValue: typeof import('./components/console/Value.vue')['default']
CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default']
Embeds: typeof import('./components/embeds/index.vue')['default']

View file

@ -0,0 +1,61 @@
<template>
<div
class="flex items-start px-4 py-2 text-tiny text-secondaryDark rounded-md"
:class="color"
>
<component :is="icon" class="mr-2 shrink-0" />
<div class="flex flex-col space-y-1 overflow-x-auto text-xs">
<div class="text-secondaryLight">{{ formattedTimestamp }}</div>
<div class="flex flex-col space-y-1">
<ConsoleValue
v-for="(arg, idx) in entry.args"
:key="idx"
:value="arg"
class="overflow-auto"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import IconAlertCircle from "~icons/lucide/alert-circle"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import IconBug from "~icons/lucide/bug"
import IconInfo from "~icons/lucide/info"
import IconTerminal from "~icons/lucide/terminal"
import { ConsoleEntry, ConsoleLogLevel } from "./Panel.vue"
type LogLevelsWithBgColor = "info" | "warn" | "error"
const props = defineProps<{
entry: ConsoleEntry
}>()
const bgColors: Pick<Record<ConsoleLogLevel, string>, LogLevelsWithBgColor> = {
info: "bg-bannerInfo",
warn: "bg-bannerWarning",
error: "bg-bannerError",
}
const icons: Record<ConsoleLogLevel, unknown> = {
info: IconInfo,
warn: IconAlertCircle,
error: IconAlertTriangle,
log: IconTerminal,
debug: IconBug,
}
const color = computed(() => bgColors[props.entry.type as LogLevelsWithBgColor])
const icon = computed(() => icons[props.entry.type])
const formattedTimestamp = computed(() => {
const dateEntry = new Date(props.entry.timestamp)
return dateEntry.toLocaleTimeString()
})
</script>

View file

@ -0,0 +1,55 @@
<template>
<div class="overflow-y-auto">
<div
class="truncate font-semibold text-secondaryLight border-b border-dividerLight px-4 py-2"
>
{{ t("app_console.entries") }}
</div>
<HoppSmartPlaceholder
v-if="renderedMessages.length === 0"
:src="`/images/states/${colorMode.value}/validation.svg`"
:alt="t('app.console.no_entries')"
:heading="t('app_console.no_entries')"
>
</HoppSmartPlaceholder>
<div v-else class="px-4 py-3 space-y-2">
<ConsoleItem
v-for="(entry, index) in renderedMessages"
:key="index"
:entry="entry"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
import { useColorMode } from "~/composables/theming"
export type ConsoleLogLevel = "log" | "warn" | "info" | "error" | "debug"
export type ConsoleEntry = {
type: ConsoleLogLevel
args: unknown[]
timestamp: number
}
const props = defineProps<{
messages: ConsoleEntry[]
}>()
const colorMode = useColorMode()
const t = useI18n()
// Filter out "clear" and compute final list to show (simulate console.clear)
const renderedMessages = computed(() => {
const output: ConsoleEntry[] = []
for (const entry of props.messages) {
output.push(entry)
}
return output
})
</script>

View file

@ -0,0 +1,101 @@
<template>
<div class="whitespace-pre-wrap font-mono text-sm">
<VueJsonPretty
v-if="isObjectOrArray"
:data="parsedValue"
:deep="2"
:class="snippetColors"
/>
<pre
v-else-if="isStringifiedObject"
class="overflow-auto max-h-96 p-4"
:class="snippetColors"
>{{ prettyStringified }}
</pre>
<pre v-else
>{{ formattedPrimitive }}
</pre>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import VueJsonPretty from "vue-json-pretty"
import "vue-json-pretty/lib/styles.css"
const props = defineProps<{ value: unknown }>()
const snippetColors = `
border rounded-md bg-gray-50 text-black !border-gray-200
dark:bg-gray-900 dark:text-gray-100 dark:!border-gray-700
`
const isObjectOrArray = computed(() => {
return typeof props.value === "object" && props.value !== null
})
const isStringifiedObject = computed(() => {
if (typeof props.value !== "string") return false
try {
const parsed = JSON.parse(props.value)
return typeof parsed === "object" && parsed !== null
} catch {
return false
}
})
const parsedValue = computed(() => {
if (isObjectOrArray.value) return props.value
if (isStringifiedObject.value && typeof props.value === "string") {
try {
return JSON.parse(props.value)
} catch {
return null
}
}
return null
})
const prettyStringified = computed(() => {
if (typeof props.value === "string") {
try {
const parsed = JSON.parse(props.value)
return JSON.stringify(parsed, null, 2)
} catch {
return props.value
}
}
return ""
})
const formattedPrimitive = computed(() => {
const val = props.value
if (typeof val === "string") {
return val
}
if (typeof val === "number" || typeof val === "boolean") {
return String(val)
}
if (val === null) {
return "null"
}
if (val === undefined) {
return "undefined"
}
try {
return JSON.stringify(val, null, 2)
} catch {
return "[Unserializable]"
}
})
</script>

View file

@ -49,19 +49,29 @@
:is-editable="false"
/>
</HoppSmartTab>
<HoppSmartTab
v-if="showConsoleTab"
id="console"
label="Console"
class="flex flex-1 flex-col"
>
<ConsolePanel :messages="consoleEntries" />
</HoppSmartTab>
</HoppSmartTabs>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import {
getSuitableLenses,
getLensRenderers,
Lens,
} from "~/helpers/lenses/lenses"
import { useI18n } from "@composables/i18n"
import { useVModel } from "@vueuse/core"
import { computed, ref, watch } from "vue"
import { useSetting } from "~/composables/settings"
import {
getLensRenderers,
getSuitableLenses,
Lens,
} from "~/helpers/lenses/lenses"
import { HoppRequestDocument } from "~/helpers/rest/document"
import { ConsoleEntry } from "../console/Panel.vue"
const props = defineProps<{
document: HoppRequestDocument
@ -75,6 +85,10 @@ const emit = defineEmits<{
}>()
const doc = useVModel(props, "document", emit)
const t = useI18n()
const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting(
"EXPERIMENTAL_SCRIPTING_SANDBOX"
)
const isSavable = computed(() => {
return doc.value.response?.type === "success" && doc.value.saveContext
@ -99,8 +113,6 @@ function lensRendererFor(name: string) {
return allLensRenderers[name]
}
const t = useI18n()
const selectedLensTab = ref("")
const maybeHeaders = computed(() => {
@ -134,6 +146,27 @@ const validLenses = computed(() => {
return getSuitableLenses(doc.value.response)
})
const showConsoleTab = computed(() => {
if (!doc.value.testResults?.consoleEntries) {
return false
}
return (
doc.value.testResults?.consoleEntries.length > 0 &&
EXPERIMENTAL_SCRIPTING_SANDBOX.value
)
})
const consoleEntries = computed(() => {
if (!doc.value.testResults?.consoleEntries) {
return []
}
return doc.value.testResults?.consoleEntries.filter(({ type }) =>
["log", "warn", "debug", "error", "info"].includes(type)
) as ConsoleEntry[]
})
watch(
validLenses,
(newLenses: Lens[]) => {

View file

@ -4,8 +4,12 @@ import {
HoppRESTRequest,
HoppRESTRequestVariable,
} from "@hoppscotch/data"
import { SandboxTestResult, TestDescriptor } from "@hoppscotch/js-sandbox"
import { runTestScript } from "@hoppscotch/js-sandbox/web"
import {
SandboxPreRequestResult,
SandboxTestResult,
TestDescriptor,
TestResult,
} from "@hoppscotch/js-sandbox"
import * as A from "fp-ts/Array"
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
@ -15,6 +19,9 @@ import { Observable, Subject } from "rxjs"
import { filter } from "rxjs/operators"
import { Ref } from "vue"
import { map } from "fp-ts/Either"
import { runTestScript } from "@hoppscotch/js-sandbox/web"
import { getService } from "~/modules/dioc"
import {
environmentsStore,
@ -31,15 +38,8 @@ import {
import { HoppTab } from "~/services/tab"
import { updateTeamEnvironment } from "./backend/mutations/TeamEnvironment"
import { createRESTNetworkRequestStream } from "./network"
import {
getCombinedEnvVariables,
getFinalEnvsFromPreRequest,
} from "./preRequest"
import { getFinalEnvsFromPreRequest } from "./preRequest"
import { HoppRequestDocument } from "./rest/document"
import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { getEffectiveRESTRequest } from "./utils/EffectiveURL"
import { isJSONContentType } from "./utils/contenttypes"
import {
getTemporaryVariables,
setTemporaryVariables,
@ -48,10 +48,31 @@ import {
CurrentValueService,
Variable,
} from "~/services/current-environment-value.service"
import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { getEffectiveRESTRequest } from "./utils/EffectiveURL"
import { isJSONContentType } from "./utils/contenttypes"
import { getCombinedEnvVariables } from "./utils/environments"
import { useSetting } from "~/composables/settings"
import {
OutgoingSandboxPostRequestWorkerMessage,
OutgoingSandboxPreRequestWorkerMessage,
} from "./workers/sandbox.worker"
const sandboxWorker = new Worker(
new URL("./workers/sandbox.worker.ts", import.meta.url),
{
type: "module",
}
)
const secretEnvironmentService = getService(SecretEnvironmentService)
const currentEnvironmentValueService = getService(CurrentValueService)
const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting(
"EXPERIMENTAL_SCRIPTING_SANDBOX"
)
export const getTestableBody = (
res: HoppRESTResponse & { type: "success" | "fail" }
) => {
@ -226,6 +247,88 @@ const filterNonEmptyEnvironmentVariables = (
return Array.from(envsMap.values())
}
const runPreRequestScript = (
script: string,
envs: {
global: Environment["variables"]
selected: Environment["variables"]
temp: Environment["variables"]
}
): Promise<E.Either<string, SandboxPreRequestResult>> => {
if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) {
return getFinalEnvsFromPreRequest(script, envs, false)
}
return new Promise((resolve) => {
const handleMessage = (
event: MessageEvent<OutgoingSandboxPreRequestWorkerMessage>
) => {
if (event.data.type === "PRE_REQUEST_SCRIPT_ERROR") {
const error =
event.data.data instanceof Error
? event.data.data.message
: String(event.data.data)
sandboxWorker.removeEventListener("message", handleMessage)
resolve(E.left(error))
}
if (event.data.type === "PRE_REQUEST_SCRIPT_RESULT") {
sandboxWorker.removeEventListener("message", handleMessage)
resolve(event.data.data)
}
}
sandboxWorker.addEventListener("message", handleMessage)
sandboxWorker.postMessage({
type: "pre",
script,
envs,
})
})
}
const runPostRequestScript = (
script: string,
envs: TestResult["envs"],
response: HoppRESTResponse
): Promise<E.Either<string, SandboxTestResult>> => {
if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) {
return runTestScript(script, envs, response, false)
}
return new Promise((resolve) => {
const handleMessage = (
event: MessageEvent<OutgoingSandboxPostRequestWorkerMessage>
) => {
if (event.data.type === "POST_REQUEST_SCRIPT_ERROR") {
const error =
event.data.data instanceof Error
? event.data.data.message
: String(event.data.data)
sandboxWorker.removeEventListener("message", handleMessage)
resolve(E.left(error))
}
if (event.data.type === "POST_REQUEST_SCRIPT_RESULT") {
sandboxWorker.removeEventListener("message", handleMessage)
resolve(event.data.data)
}
}
sandboxWorker.addEventListener("message", handleMessage)
sandboxWorker.postMessage({
type: "post",
script,
envs,
response,
})
})
}
export function runRESTRequest$(
tab: Ref<HoppTab<HoppRequestDocument>>
): [
@ -243,14 +346,14 @@ export function runRESTRequest$(
cancelFunc?.()
}
const res = getFinalEnvsFromPreRequest(
const res = runPreRequestScript(
tab.value.document.request.preRequestScript,
getCombinedEnvVariables()
).then(async (envs) => {
).then(async (preRequestScriptResult) => {
if (cancelCalled) return E.left("cancellation" as const)
if (E.isLeft(envs)) {
console.error(envs.left)
if (E.isLeft(preRequestScriptResult)) {
console.error(preRequestScriptResult.left)
return E.left("script_fail" as const)
}
@ -302,7 +405,7 @@ export function runRESTRequest$(
const finalEnvs = {
requestVariables: finalRequestVariables as Environment["variables"],
environments: envs.right,
environments: preRequestScriptResult.right.envs,
}
const finalEnvsWithNonEmptyValues = filterNonEmptyEnvironmentVariables(
@ -326,9 +429,9 @@ export function runRESTRequest$(
if (res.type === "success" || res.type === "fail") {
executedResponses$.next(res)
const runResult = await runTestScript(
const postRequestScriptResult = await runPostRequestScript(
res.req.testScript,
envs.right,
preRequestScriptResult.right.envs,
{
status: res.statusCode,
body: getTestableBody(res),
@ -336,13 +439,26 @@ export function runRESTRequest$(
}
)
if (E.isRight(runResult)) {
if (E.isRight(postRequestScriptResult)) {
// set the response in the tab so that multiple tabs can run request simultaneously
tab.value.document.response = res
tab.value.document.testResults = translateToSandboxTestResults(
runResult.right
)
updateEnvsAfterTestScript(runResult)
// Combine console entries from pre and post request scripts
const combinedResult = pipe(
postRequestScriptResult,
map((result) => ({
...result,
consoleEntries: [
...(preRequestScriptResult.right.consoleEntries ?? []),
...(result.consoleEntries ?? []),
],
}))
) as E.Right<SandboxTestResult>
const updatedRunResult = updateEnvsAfterTestScript(combinedResult)
tab.value.document.testResults =
translateToSandboxTestResults(updatedRunResult)
} else {
tab.value.document.testResults = {
description: "",
@ -361,6 +477,7 @@ export function runRESTRequest$(
},
},
scriptError: true,
consoleEntries: [],
}
}
@ -452,12 +569,12 @@ export function runTestRunnerRequest(
}>
| undefined
> {
return getFinalEnvsFromPreRequest(
return runPreRequestScript(
request.preRequestScript,
getCombinedEnvVariables()
).then(async (envs) => {
if (E.isLeft(envs)) {
console.error(envs.left)
).then(async (preRequestScriptResult) => {
if (E.isLeft(preRequestScriptResult)) {
console.error(preRequestScriptResult.left)
return E.left("script_fail" as const)
}
@ -467,7 +584,7 @@ export function runTestRunnerRequest(
name: "Env",
variables: combineEnvVariables({
environments: {
...envs.right,
...preRequestScriptResult.right.envs,
temp: !persistEnv ? getTemporaryVariables() : [],
},
requestVariables: [],
@ -483,9 +600,9 @@ export function runTestRunnerRequest(
if (res?.type === "success" || res?.type === "fail") {
executedResponses$.next(res)
const runResult = await runTestScript(
const postRequestScriptResult = await runPostRequestScript(
res.req.testScript,
envs.right,
preRequestScriptResult.right.envs,
{
status: res.statusCode,
body: getTestableBody(res),
@ -493,19 +610,27 @@ export function runTestRunnerRequest(
}
)
if (E.isRight(runResult)) {
const sandboxTestResult = translateToSandboxTestResults(
runResult.right
)
if (E.isRight(postRequestScriptResult)) {
// Combine console entries from pre and post request scripts
const combinedResult = {
...postRequestScriptResult.right,
consoleEntries: [
...(preRequestScriptResult.right.consoleEntries ?? []),
...(postRequestScriptResult.right.consoleEntries ?? []),
],
}
const sandboxTestResult =
translateToSandboxTestResults(combinedResult)
// Update the environment variables after running the test script when persistEnv is true. else store the updated environment variables in the store as a temporary variable.
if (persistEnv) {
updateEnvsAfterTestScript(runResult)
updateEnvsAfterTestScript(postRequestScriptResult)
} else {
// Combine global and selected environment changes
const allChanges = [
...runResult.right.envs.global,
...runResult.right.envs.selected,
...postRequestScriptResult.right.envs.global,
...postRequestScriptResult.right.envs.selected,
]
setTemporaryVariables(allChanges)
@ -533,6 +658,7 @@ export function runTestRunnerRequest(
},
},
scriptError: true,
consoleEntries: [],
}
return E.right({
response: res,
@ -631,5 +757,6 @@ function translateToSandboxTestResults(
updations: getUpdatedEnvVariables(envVars, testDesc.envs.selected),
},
},
consoleEntries: testDesc.consoleEntries,
}
}

View file

@ -1,8 +1,8 @@
import { getService } from "~/modules/dioc"
import { getCombinedEnvVariables } from "../preRequest"
import { RESTTabService } from "~/services/tab/rest"
import { parseTemplateStringE } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { getCombinedEnvVariables } from "../utils/environments"
export const replaceTemplateStringsInObjectValues = <
T extends Record<string, unknown>,

View file

@ -1,86 +1,8 @@
import * as E from "fp-ts/Either"
import { runPreRequestScript } from "@hoppscotch/js-sandbox/web"
import { Environment } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { runPreRequestScript } from "@hoppscotch/js-sandbox/web"
import * as E from "fp-ts/Either"
import {
getCurrentEnvironment,
getGlobalVariables,
} from "~/newstore/environments"
import { TestResult } from "@hoppscotch/js-sandbox"
import { getService } from "~/modules/dioc"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
import { CurrentValueService } from "~/services/current-environment-value.service"
const secretEnvironmentService = getService(SecretEnvironmentService)
const currentEnvironmentValueService = getService(CurrentValueService)
/**
* Populate the currentValue of the environment variables and set the secret values
* @param selected
* @param global
* @returns
*/
const unWrapEnvironments = (
selected: Environment,
global: Environment["variables"]
) => {
const resolvedGlobalWithSecrets = global.map((globalVar, index) => {
const secretVar = secretEnvironmentService.getSecretEnvironmentVariable(
"Global",
index
)
const currentVar = currentEnvironmentValueService.getEnvironmentVariable(
"Global",
index
)
if (secretVar) {
return {
...globalVar,
currentValue: secretVar.value,
}
}
return { ...globalVar, currentValue: currentVar?.currentValue ?? "" }
})
const resolvedSelectedWithSecrets = selected.variables.map(
(selectedVar, index) => {
const secretVar = secretEnvironmentService.getSecretEnvironmentVariable(
selected.id,
index
)
const currentVar = currentEnvironmentValueService.getEnvironmentVariable(
selected.id,
index
)
if (secretVar) {
return {
...selectedVar,
currentValue: secretVar.value,
}
}
return { ...selectedVar, currentValue: currentVar?.currentValue ?? "" }
}
)
return {
global: resolvedGlobalWithSecrets,
selected: resolvedSelectedWithSecrets,
}
}
export const getCombinedEnvVariables = (temp?: Environment["variables"]) => {
const reformedVars = unWrapEnvironments(
getCurrentEnvironment(),
getGlobalVariables()
)
return {
global: cloneDeep(reformedVars.global),
selected: cloneDeep(reformedVars.selected),
temp: temp ? cloneDeep(temp) : [],
}
}
import { SandboxPreRequestResult } from "@hoppscotch/js-sandbox"
export const getFinalEnvsFromPreRequest = (
script: string,
@ -88,6 +10,7 @@ export const getFinalEnvsFromPreRequest = (
global: Environment["variables"]
selected: Environment["variables"]
temp: Environment["variables"]
}
): Promise<E.Either<string, TestResult["envs"]>> =>
runPreRequestScript(script, envs)
},
experimentalScriptingSandbox = true
): Promise<E.Either<string, SandboxPreRequestResult>> =>
runPreRequestScript(script, envs, experimentalScriptingSandbox)

View file

@ -1,4 +1,5 @@
import { Environment } from "@hoppscotch/data"
import { SandboxTestResult } from "@hoppscotch/js-sandbox"
export type HoppTestExpectResult = {
status: "fail" | "pass" | "error"
@ -33,4 +34,6 @@ export type HoppTestResult = {
deletions: Environment["variables"]
}
}
consoleEntries: SandboxTestResult["consoleEntries"]
}

View file

@ -0,0 +1,80 @@
import { Environment } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { getService } from "~/modules/dioc"
import {
getCurrentEnvironment,
getGlobalVariables,
} from "~/newstore/environments"
import { CurrentValueService } from "~/services/current-environment-value.service"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
const secretEnvironmentService = getService(SecretEnvironmentService)
const currentEnvironmentValueService = getService(CurrentValueService)
/**
* Populate the currentValue of the environment variables and set the secret values
* @param selected
* @param global
* @returns
*/
const unWrapEnvironments = (
selected: Environment,
global: Environment["variables"]
) => {
const resolvedGlobalWithSecrets = global.map((globalVar, index) => {
const secretVar = secretEnvironmentService.getSecretEnvironmentVariable(
"Global",
index
)
const currentVar = currentEnvironmentValueService.getEnvironmentVariable(
"Global",
index
)
if (secretVar) {
return {
...globalVar,
currentValue: secretVar.value,
}
}
return { ...globalVar, currentValue: currentVar?.currentValue ?? "" }
})
const resolvedSelectedWithSecrets = selected.variables.map(
(selectedVar, index) => {
const secretVar = secretEnvironmentService.getSecretEnvironmentVariable(
selected.id,
index
)
const currentVar = currentEnvironmentValueService.getEnvironmentVariable(
selected.id,
index
)
if (secretVar) {
return {
...selectedVar,
currentValue: secretVar.value,
}
}
return { ...selectedVar, currentValue: currentVar?.currentValue ?? "" }
}
)
return {
global: resolvedGlobalWithSecrets,
selected: resolvedSelectedWithSecrets,
}
}
export const getCombinedEnvVariables = (temp?: Environment["variables"]) => {
const reformedVars = unWrapEnvironments(
getCurrentEnvironment(),
getGlobalVariables()
)
return {
global: cloneDeep(reformedVars.global),
selected: cloneDeep(reformedVars.selected),
temp: temp ? cloneDeep(temp) : [],
}
}

View file

@ -0,0 +1,108 @@
import { Environment } from "@hoppscotch/data"
import {
SandboxPreRequestResult,
SandboxTestResult,
TestResult,
} from "@hoppscotch/js-sandbox"
import { runTestScript } from "@hoppscotch/js-sandbox/web"
import * as E from "fp-ts/Either"
import { getFinalEnvsFromPreRequest } from "../preRequest"
import { HoppRESTResponse } from "../types/HoppRESTResponse"
interface PreRequestMessage {
type: "pre"
script: string
envs: {
global: Environment["variables"]
selected: Environment["variables"]
temp: Environment["variables"]
}
}
interface PostRequestMessage {
type: "post"
script: string
envs: TestResult["envs"]
response: HoppRESTResponse
}
type IncomingSandboxWorkerMessage = PreRequestMessage | PostRequestMessage
interface PreRequestScriptResultMessage {
type: "PRE_REQUEST_SCRIPT_RESULT"
data: E.Either<string, SandboxPreRequestResult>
}
interface PreRequestScriptErrorMessage {
type: "PRE_REQUEST_SCRIPT_ERROR"
data: unknown
}
interface PostRequestScriptResultMessage {
type: "POST_REQUEST_SCRIPT_RESULT"
data: E.Either<string, SandboxTestResult>
}
interface PostRequestScriptErrorMessage {
type: "POST_REQUEST_SCRIPT_ERROR"
data: unknown
}
export type OutgoingSandboxPreRequestWorkerMessage =
| PreRequestScriptResultMessage
| PreRequestScriptErrorMessage
export type OutgoingSandboxPostRequestWorkerMessage =
| PostRequestScriptResultMessage
| PostRequestScriptErrorMessage
self.addEventListener(
"message",
async (event: MessageEvent<IncomingSandboxWorkerMessage>) => {
const { type, script, envs } = event.data
if (type === "pre") {
try {
const preRequestScriptResult = await getFinalEnvsFromPreRequest(
script,
envs
)
const result: PreRequestScriptResultMessage = {
type: "PRE_REQUEST_SCRIPT_RESULT",
data: preRequestScriptResult,
}
self.postMessage(result)
} catch (error) {
const err: PreRequestScriptErrorMessage = {
type: "PRE_REQUEST_SCRIPT_ERROR",
data: error,
}
self.postMessage(err)
}
}
if (type === "post") {
const { response } = event.data
try {
const postRequestScriptResult = await runTestScript(
script,
envs,
response
)
const result: PostRequestScriptResultMessage = {
type: "POST_REQUEST_SCRIPT_RESULT",
data: postRequestScriptResult,
}
self.postMessage(result)
} catch (error) {
const err: PostRequestScriptErrorMessage = {
type: "POST_REQUEST_SCRIPT_ERROR",
data: error,
}
self.postMessage(err)
}
}
}
)

View file

@ -82,6 +82,8 @@ export type SettingsDef = {
| "PascalCase"
| "CUSTOM"
CUSTOM_NAMING_STYLE: string
EXPERIMENTAL_SCRIPTING_SANDBOX: boolean
}
let defaultProxyURL = DEFAULT_HOPP_PROXY_URL
@ -142,6 +144,8 @@ export const getDefaultSettings = (): SettingsDef => {
ENABLE_AI_EXPERIMENTS: true,
AI_REQUEST_NAMING_STYLE: "DESCRIPTIVE_WITH_SPACES",
CUSTOM_NAMING_STYLE: "",
EXPERIMENTAL_SCRIPTING_SANDBOX: true,
}
}

View file

@ -149,12 +149,21 @@
)
"
rows="4"
/>
>
</textarea>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="EXPERIMENTAL_SCRIPTING_SANDBOX"
@change="toggleSetting('EXPERIMENTAL_SCRIPTING_SANDBOX')"
>
{{ t("settings.experimental_scripting_sandbox") }}
</HoppSmartToggle>
</div>
</section>
</div>
</div>
@ -342,6 +351,10 @@ const ENABLE_AI_EXPERIMENTS = useSetting("ENABLE_AI_EXPERIMENTS")
const AI_REQUEST_NAMING_STYLE = useSetting("AI_REQUEST_NAMING_STYLE")
const CUSTOM_NAMING_STYLE = useSetting("CUSTOM_NAMING_STYLE")
const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting(
"EXPERIMENTAL_SCRIPTING_SANDBOX"
)
const supportedNamingStyles = [
{
id: "DESCRIPTIVE_WITH_SPACES" as const,

View file

@ -82,6 +82,8 @@ const SettingsDefSchema = z.object({
.optional()
.catch("DESCRIPTIVE_WITH_SPACES"),
CUSTOM_NAMING_STYLE: z.string().optional().catch(""),
EXPERIMENTAL_SCRIPTING_SANDBOX: z.optional(z.boolean()),
})
const HoppRESTRequestSchema = entityReference(HoppRESTRequest)
@ -431,6 +433,7 @@ const HoppTestResultSchema = z
.strict(),
})
.strict(),
consoleEntries: z.array(z.record(z.string(), z.unknown()).optional()),
})
.strict()

View file

@ -1,6 +1,14 @@
import * as E from "fp-ts/Either"
import { expect } from "vitest"
globalThis.Worker = class {
constructor() {}
postMessage = () => {}
terminate = () => {}
onmessage = null
onerror = null
} as any
expect.extend({
toBeLeft(received, expected) {
const { isNot } = this

View file

@ -24,7 +24,7 @@
"csp": {
"default-src": "blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' customprotocol: asset:",
"script-src": "* 'self' 'unsafe-eval' 'wasm-unsafe-eval' 'unsafe-inline'",
"connect-src": "ipc: http://ipc.localhost https://api.hoppscotch.io *",
"connect-src": "ipc: http://ipc.localhost https://api.hoppscotch.io data: *",
"font-src": "https://fonts.gstatic.com data: 'self' *",
"img-src": "'self' asset: http://asset.localhost blob: data: customprotocol: *",
"style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com data: asset: *",

View file

@ -53,6 +53,7 @@
"dependencies": {
"@hoppscotch/data": "workspace:^",
"@types/lodash-es": "4.17.12",
"faraday-cage": "0.0.15",
"fp-ts": "2.16.9",
"lodash": "4.17.21",
"lodash-es": "4.17.21"

View file

@ -18,410 +18,412 @@ const func = (script: string, res: TestResponse) =>
TE.map((x) => x.tests)
)
describe("toBeLevel2xx", () => {
test("assertion passes for 200 series with no negation", async () => {
for (let i = 200; i < 300; i++) {
describe("toBeLevelxxx", { timeout: 100000 }, () => {
describe("toBeLevel2xx", () => {
test("assertion passes for 200 series with no negation", async () => {
for (let i = 200; i < 300; i++) {
await expect(
func(`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(
func(`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(
func(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)()
func(`pw.expect("foo").toBeLevel2xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{
status: "pass",
message: `Expected '${i}' to be 200-level status`,
status: "error",
message:
"Expected 200-level status but could not parse value 'foo'",
},
],
}),
])
}
})
})
test("assertion fails for non 200 series with no negation", async () => {
for (let i = 300; i < 500; i++) {
test("assertion fails for 200 series with negation", async () => {
for (let i = 200; i < 300; i++) {
await expect(
func(`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(
func(`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(
func(`pw.expect(${i}).toBeLevel2xx()`, fakeResponse)()
func(`pw.expect("foo").not.toBeLevel2xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{
status: "fail",
message: `Expected '${i}' to be 200-level status`,
status: "error",
message:
"Expected 200-level status but could not parse value 'foo'",
},
],
}),
])
}
})
})
test("give error if the expect value was not a number with no negation", async () => {
await expect(
func(`pw.expect("foo").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(
func(`pw.expect(${i}).toBeLevel3xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{
status: "pass",
message: `Expected '${i}' to be 300-level status`,
},
],
}),
])
}
})
test("assertion fails for 200 series with negation", async () => {
for (let i = 200; i < 300; i++) {
await expect(
func(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)()
test("assertion fails for non 300 series with no negation", async () => {
for (let i = 400; i < 500; i++) {
await expect(
func(`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(
func(`pw.expect("foo").toBeLevel3xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{
status: "fail",
message: `Expected '${i}' to not be 200-level status`,
status: "error",
message:
"Expected 300-level status but could not parse value 'foo'",
},
],
}),
])
}
})
})
test("assertion passes for non 200 series with negation", async () => {
for (let i = 300; i < 500; i++) {
await expect(
func(`pw.expect(${i}).not.toBeLevel2xx()`, fakeResponse)()
test("assertion fails for 400 series with negation", async () => {
for (let i = 300; i < 400; i++) {
await expect(
func(`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(
func(`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(
func(`pw.expect("foo").not.toBeLevel3xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{
status: "pass",
message: `Expected '${i}' to not be 200-level status`,
status: "error",
message:
"Expected 300-level status but could not parse value 'foo'",
},
],
}),
])
}
})
})
test("give error if the expect value was not a number with negation", async () => {
await expect(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`pw.expect("foo").not.toBeLevel5xx()`, fakeResponse)()
).resolves.toEqualRight([
expect.objectContaining({
expectResults: [
{
status: "error",
message:
"Expected 500-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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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(
func(`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

@ -28,6 +28,14 @@ describe("toBeType", () => {
pw.expect(true).toBeType("boolean")
pw.expect({}).toBeType("object")
pw.expect(undefined).toBeType("undefined")
const utcDate = new Date("2025-05-22T17:42:26Z")
const istOffset = 5.5 * 60 * 60 * 1000 // 5.5 hours in ms
const istDate = new Date(utcDate.getTime() + istOffset)
pw.expect(istDate).toBeType("object")
pw.expect(istDate.toISOString()).toBeType("string")
pw.expect(JSON.stringify(istDate)).toBeType("string")
`,
fakeResponse
)()
@ -45,6 +53,18 @@ describe("toBeType", () => {
status: "pass",
message: `Expected 'undefined' to be type 'undefined'`,
},
{
message: expect.stringMatching(/to be type 'object'$/),
status: "pass",
},
{
message: "Expected '2025-05-22T23:12:26.000Z' to be type 'string'",
status: "pass",
},
{
message: `Expected '"2025-05-22T23:12:26.000Z"' to be type 'string'`,
status: "pass",
},
],
}),
])
@ -124,6 +144,14 @@ describe("toBeType", () => {
pw.expect(true).not.toBeType("string")
pw.expect({}).not.toBeType("number")
pw.expect(undefined).not.toBeType("number")
const utcDate = new Date("2025-05-22T17:42:26Z")
const istOffset = 5.5 * 60 * 60 * 1000 // 5.5 hours in ms
const istDate = new Date(utcDate.getTime() + istOffset)
pw.expect(istDate).not.toBeType("string")
pw.expect(istDate.toISOString()).not.toBeType("object")
pw.expect(JSON.stringify(istDate)).not.toBeType("object")
`,
fakeResponse
)()
@ -144,6 +172,19 @@ describe("toBeType", () => {
status: "pass",
message: `Expected 'undefined' to not be type 'number'`,
},
{
message: expect.stringMatching(/to not be type 'string'$/),
status: "pass",
},
{
message:
"Expected '2025-05-22T23:12:26.000Z' to not be type 'object'",
status: "pass",
},
{
message: `Expected '"2025-05-22T23:12:26.000Z"' to not be type 'object'`,
status: "pass",
},
],
}),
])

View file

@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
;(inputs) => {
globalThis.pw = {
env: {
get: (key) => inputs.envGet(key),
getResolve: (key) => inputs.envGetResolve(key),
set: (key, value) => inputs.envSet(key, value),
unset: (key) => inputs.envUnset(key),
resolve: (key) => inputs.envResolve(key),
},
expect: (expectVal) => {
return {
toBe: (expectedVal) => inputs.expectToBe(expectVal, expectedVal),
toBeLevel2xx: (expectedVal) =>
inputs.expectToBeLevel2xx(expectVal, expectedVal),
toBeLevel3xx: (expectedVal) =>
inputs.expectToBeLevel3xx(expectVal, expectedVal),
toBeLevel4xx: (expectedVal) =>
inputs.expectToBeLevel4xx(expectVal, expectedVal),
toBeLevel5xx: (expectedVal) =>
inputs.expectToBeLevel5xx(expectVal, expectedVal),
toBeType: (expectedVal) => {
const isExpectValDateInstance = expectVal instanceof Date
return inputs.expectToBeType(
expectVal,
expectedVal,
isExpectValDateInstance
)
},
toHaveLength: (expectedVal) =>
inputs.expectToHaveLength(expectVal, expectedVal),
toInclude: (expectedVal) =>
inputs.expectToInclude(expectVal, expectedVal),
not: {
toBe: (expectedVal) => inputs.expectNotToBe(expectVal, expectedVal),
toBeLevel2xx: (expectedVal) =>
inputs.expectNotToBeLevel2xx(expectVal, expectedVal),
toBeLevel3xx: (expectedVal) =>
inputs.expectNotToBeLevel3xx(expectVal, expectedVal),
toBeLevel4xx: (expectedVal) =>
inputs.expectNotToBeLevel4xx(expectVal, expectedVal),
toBeLevel5xx: (expectedVal) =>
inputs.expectNotToBeLevel5xx(expectVal, expectedVal),
toBeType: (expectedVal) => {
const isExpectValDateInstance = expectVal instanceof Date
return inputs.expectNotToBeType(
expectVal,
expectedVal,
isExpectValDateInstance
)
},
toHaveLength: (expectedVal) =>
inputs.expectNotToHaveLength(expectVal, expectedVal),
toInclude: (expectedVal) =>
inputs.expectNotToInclude(expectVal, expectedVal),
},
}
},
test: (descriptor, testFn) => {
inputs.preTest(descriptor)
testFn()
inputs.postTest()
},
response: inputs.getResponse(),
}
}

View file

@ -0,0 +1,12 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
;(inputs) => {
globalThis.pw = {
env: {
get: (key) => inputs.envGet(key),
getResolve: (key) => inputs.envGetResolve(key),
set: (key, value) => inputs.envSet(key, value),
unset: (key) => inputs.envUnset(key),
resolve: (key) => inputs.envResolve(key),
},
}
}

View file

@ -0,0 +1,268 @@
import {
defineCageModule,
defineSandboxFn,
defineSandboxObject,
} from "faraday-cage/modules"
import { createExpectation, getSharedMethods } from "~/shared-utils"
import { TestDescriptor, TestResponse, TestResult } from "~/types"
import postRequestBootstrapCode from "../bootstrap-code/post-request?raw"
import preRequestBootstrapCode from "../bootstrap-code/pre-request?raw"
type PwPostRequestModuleConfig = {
envs: TestResult["envs"]
testRunStack: TestDescriptor[]
response: TestResponse
handleSandboxResults: ({
envs,
testRunStack,
}: {
envs: TestResult["envs"]
testRunStack: TestDescriptor[]
}) => void
}
type PwPreRequestModuleConfig = {
envs: TestResult["envs"]
handleSandboxResults: ({ envs }: { envs: TestResult["envs"] }) => void
}
type PwModuleType = "pre" | "post"
type PwModuleConfig = PwPreRequestModuleConfig | PwPostRequestModuleConfig
const createPwInputsObj = (
ctx: any,
methods: any,
type: PwModuleType,
config: PwModuleConfig
) => {
const baseInputs = {
envGet: defineSandboxFn(ctx, "get", (key) => methods.env.get(key)),
envGetResolve: defineSandboxFn(ctx, "getResolve", (key) =>
methods.env.getResolve(key)
),
envSet: defineSandboxFn(ctx, "set", (key, value) => {
return methods.env.set(key, value)
}),
envUnset: defineSandboxFn(ctx, "unset", (key) => methods.env.unset(key)),
envResolve: defineSandboxFn(ctx, "resolve", (key) =>
methods.env.resolve(key)
),
}
if (type === "post") {
const postConfig = config as PwPostRequestModuleConfig
return {
...baseInputs,
expectToBe: defineSandboxFn(ctx, "toBe", (expectVal, expectedVal) =>
createExpectation(expectVal, false, postConfig.testRunStack).toBe(
expectedVal
)
),
expectToBeLevel2xx: defineSandboxFn(ctx, "toBeLevel2xx", (expectVal) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).toBeLevel2xx()
),
expectToBeLevel3xx: defineSandboxFn(ctx, "toBeLevel3xx", (expectVal) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).toBeLevel3xx()
),
expectToBeLevel4xx: defineSandboxFn(ctx, "toBeLevel4xx", (expectVal) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).toBeLevel4xx()
),
expectToBeLevel5xx: defineSandboxFn(ctx, "toBeLevel5xx", (expectVal) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).toBeLevel5xx()
),
expectToBeType: defineSandboxFn(
ctx,
"toBeType",
(expectVal, expectedType, isExpectValDateInstance) => {
// Supplying `new Date()` in the script gets serialized in the sandbox context
// Parse the string back to a date instance
const resolvedExpectVal =
isExpectValDateInstance && typeof expectVal === "string"
? new Date(expectVal)
: expectVal
return createExpectation(
resolvedExpectVal,
false,
postConfig.testRunStack
).toBeType(expectedType)
}
),
expectToHaveLength: defineSandboxFn(
ctx,
"toHaveLength",
(expectVal, expectedLength) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).toHaveLength(expectedLength)
),
expectToInclude: defineSandboxFn(ctx, "toInclude", (expectVal, needle) =>
createExpectation(expectVal, false, postConfig.testRunStack).toInclude(
needle
)
),
expectNotToBe: defineSandboxFn(ctx, "notToBe", (expectVal, expectedVal) =>
createExpectation(expectVal, false, postConfig.testRunStack).not.toBe(
expectedVal
)
),
expectNotToBeLevel2xx: defineSandboxFn(
ctx,
"notToBeLevel2xx",
(expectVal) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).not.toBeLevel2xx()
),
expectNotToBeLevel3xx: defineSandboxFn(
ctx,
"notToBeLevel3xx",
(expectVal) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).not.toBeLevel3xx()
),
expectNotToBeLevel4xx: defineSandboxFn(
ctx,
"notToBeLevel4xx",
(expectVal) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).not.toBeLevel4xx()
),
expectNotToBeLevel5xx: defineSandboxFn(
ctx,
"notToBeLevel5xx",
(expectVal) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).not.toBeLevel5xx()
),
expectNotToBeType: defineSandboxFn(
ctx,
"notToBeType",
(expectVal, expectedType, isExpectValDateInstance) => {
// Supplying `new Date()` in the script gets serialized in the sandbox context
// Parse the string back to a date instance
const resolvedExpectVal =
isExpectValDateInstance && typeof expectVal === "string"
? new Date(expectVal)
: expectVal
return createExpectation(
resolvedExpectVal,
false,
postConfig.testRunStack
).not.toBeType(expectedType)
}
),
expectNotToHaveLength: defineSandboxFn(
ctx,
"notToHaveLength",
(expectVal, expectedLength) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).not.toHaveLength(expectedLength)
),
expectNotToInclude: defineSandboxFn(
ctx,
"notToInclude",
(expectVal, needle) =>
createExpectation(
expectVal,
false,
postConfig.testRunStack
).not.toInclude(needle)
),
preTest: defineSandboxFn(ctx, "preTest", (descriptor: any) => {
postConfig.testRunStack.push({
descriptor,
expectResults: [],
children: [],
})
}),
postTest: defineSandboxFn(ctx, "postTest", () => {
const child = postConfig.testRunStack.pop() as TestDescriptor
postConfig.testRunStack[
postConfig.testRunStack.length - 1
].children.push(child)
}),
getResponse: defineSandboxFn(
ctx,
"getResponse",
() => postConfig.response
),
}
}
return baseInputs
}
const createPwModule = (
type: PwModuleType,
bootstrapCode: string,
config: PwModuleConfig
) => {
return defineCageModule((ctx) => {
const funcHandle = ctx.scope.manage(ctx.vm.evalCode(bootstrapCode)).unwrap()
const { methods, updatedEnvs } = getSharedMethods(config.envs)
const inputsObj = defineSandboxObject(
ctx,
createPwInputsObj(ctx, methods, type, config)
)
ctx.vm.callFunction(funcHandle, ctx.vm.undefined, inputsObj)
ctx.afterScriptExecutionHooks.push(() => {
if (type === "post") {
const postConfig = config as PwPostRequestModuleConfig
postConfig.handleSandboxResults({
envs: updatedEnvs,
testRunStack: postConfig.testRunStack,
})
} else {
const preConfig = config as PwPreRequestModuleConfig
preConfig.handleSandboxResults({
envs: updatedEnvs,
})
}
})
})
}
export const pwPostRequestModule = (config: PwPostRequestModuleConfig) =>
createPwModule("post", postRequestBootstrapCode, config)
export const pwPreRequestModule = (config: PwPreRequestModuleConfig) =>
createPwModule("pre", preRequestBootstrapCode, config)

View file

@ -1,10 +1,20 @@
import { FaradayCage } from "faraday-cage"
import {
blobPolyfill,
console as ConsoleModule,
crypto,
esmModuleLoader,
fetch,
} from "faraday-cage/modules"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/lib/TaskEither"
import { createRequire } from "module"
import type ivmT from "isolated-vm"
import { cloneDeep } from "lodash"
import { pwPreRequestModule } from "~/cage-modules/pw"
import { createRequire } from "module"
import { TestResult } from "~/types"
import { getPreRequestScriptMethods } from "~/shared-utils"
import { getSerializedAPIMethods } from "./utils"
@ -13,79 +23,143 @@ const ivm = nodeRequire("isolated-vm")
export const runPreRequestScript = (
preRequestScript: string,
envs: TestResult["envs"]
): TE.TaskEither<string, TestResult["envs"]> =>
pipe(
TE.tryCatch(
async () => {
const isolate: ivmT.Isolate = new ivm.Isolate()
const context = await isolate.createContext()
return { isolate, context }
},
(reason) => `Context initialization failed: ${reason}`
),
TE.chain(({ isolate, context }) =>
pipe(
TE.tryCatch(
async () => {
const jail = context.global
const { pw, updatedEnvs } = getPreRequestScriptMethods(envs)
const serializedAPIMethods = getSerializedAPIMethods(pw)
jail.setSync("serializedAPIMethods", serializedAPIMethods, {
copy: true,
})
jail.setSync("atob", atob)
jail.setSync("btoa", btoa)
// Methods in the isolate context can't be invoked straightaway
const finalScript = `
const pw = new Proxy(serializedAPIMethods, {
get: (pwObjTarget, pwObjProp) => {
const topLevelEntry = pwObjTarget[pwObjProp]
// "pw.env" set of API methods
if (topLevelEntry && typeof topLevelEntry === "object") {
return new Proxy(topLevelEntry, {
get: (subTarget, subProp) => {
const subLevelProperty = subTarget[subProp]
if (subLevelProperty && subLevelProperty.typeof === "function") {
return (...args) => subLevelProperty.applySync(null, args)
}
},
})
}
}
envs: TestResult["envs"],
experimentalScriptingSandbox = true
): TE.TaskEither<string, TestResult["envs"]> => {
if (!experimentalScriptingSandbox) {
return pipe(
TE.tryCatch(
async () => {
const isolate: ivmT.Isolate = new ivm.Isolate()
const context = await isolate.createContext()
return { isolate, context }
},
(reason) => `Context initialization failed: ${reason}`
),
TE.chain(({ isolate, context }) =>
pipe(
TE.tryCatch(
async () => {
const jail = context.global
const { pw, updatedEnvs } = getPreRequestScriptMethods(envs)
const serializedAPIMethods = getSerializedAPIMethods(pw)
jail.setSync("serializedAPIMethods", serializedAPIMethods, {
copy: true,
})
${preRequestScript}
`
// Create a script and compile it
const script = await isolate.compileScript(finalScript)
// Run the pre-request script in the provided context
await script.run(context)
return updatedEnvs
},
(reason) => reason
),
TE.fold(
(error) => TE.left(`Script execution failed: ${error}`),
(result) =>
pipe(
TE.tryCatch(
async () => {
await isolate.dispose()
return result
},
(disposeError) => `Isolate disposal failed: ${disposeError}`
jail.setSync("atob", atob)
jail.setSync("btoa", btoa)
// Methods in the isolate context can't be invoked straightaway
const finalScript = `
const pw = new Proxy(serializedAPIMethods, {
get: (pwObjTarget, pwObjProp) => {
const topLevelEntry = pwObjTarget[pwObjProp]
// "pw.env" set of API methods
if (topLevelEntry && typeof topLevelEntry === "object") {
return new Proxy(topLevelEntry, {
get: (subTarget, subProp) => {
const subLevelProperty = subTarget[subProp]
if (subLevelProperty && subLevelProperty.typeof === "function") {
return (...args) => subLevelProperty.applySync(null, args)
}
},
})
}
}
})
${preRequestScript}
`
// Create a script and compile it
const script = await isolate.compileScript(finalScript)
// Run the pre-request script in the provided context
await script.run(context)
return updatedEnvs
},
(reason) => reason
),
TE.fold(
(error) => TE.left(`Script execution failed: ${error}`),
(result) =>
pipe(
TE.tryCatch(
async () => {
await isolate.dispose()
return result
},
(disposeError) => `Isolate disposal failed: ${disposeError}`
)
)
)
)
)
)
)
}
return pipe(
TE.tryCatch(
async (): Promise<TestResult["envs"]> => {
let finalEnvs = envs
const cage = await FaradayCage.create()
const result = await cage.runCode(preRequestScript, [
pwPreRequestModule({
envs: cloneDeep(envs),
handleSandboxResults: ({ envs }) => {
finalEnvs = envs
},
}),
blobPolyfill,
ConsoleModule({
onLog(...args) {
console[args[0]](...args)
},
onCount(...args) {
console.count(args[0])
},
onTime(...args) {
console.timeEnd(args[0])
},
onTimeLog(...args) {
console.timeLog(...args)
},
onGroup(...args) {
console.group(...args)
},
onGroupEnd(...args) {
console.groupEnd(...args)
},
onClear(...args) {
console.clear(...args)
},
onAssert(...args) {
console.assert(...args)
},
onDir(...args) {
console.dir(...args)
},
onTable(...args) {
console.table(...args)
},
}),
crypto(),
esmModuleLoader,
fetch(),
])
if (result.type === "error") {
throw result.err
}
return finalEnvs
},
(error) => {
if (error !== null && typeof error === "object" && "message" in error) {
const reason = `${"name" in error ? error.name : ""}: ${error.message}`
return `Script execution failed: ${reason}`
}
return `Script execution failed: ${String(error)}`
}
)
)
}

View file

@ -1,15 +1,24 @@
import { FaradayCage } from "faraday-cage"
import {
blobPolyfill,
console as ConsoleModule,
crypto,
esmModuleLoader,
fetch,
} from "faraday-cage/modules"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { createRequire } from "module"
import type ivmT from "isolated-vm"
import { cloneDeep } from "lodash"
import { createRequire } from "module"
import { pwPostRequestModule } from "~/cage-modules/pw"
import { TestResponse, TestResult } from "~/types"
import {
getTestRunnerScriptMethods,
preventCyclicObjects,
} from "~/shared-utils"
import { TestDescriptor, TestResponse, TestResult } from "~/types"
import { getSerializedAPIMethods } from "./utils"
const nodeRequire = createRequire(import.meta.url)
@ -18,42 +27,133 @@ const ivm = nodeRequire("isolated-vm")
export const runTestScript = (
testScript: string,
envs: TestResult["envs"],
response: TestResponse
): TE.TaskEither<string, TestResult> =>
pipe(
TE.tryCatch(
async () => {
const isolate: ivmT.Isolate = new ivm.Isolate()
const context = await isolate.createContext()
return { isolate, context }
},
(reason) => `Context initialization failed: ${reason}`
),
TE.chain(({ isolate, context }) =>
pipe(
TE.tryCatch(
async () =>
executeScriptInContext(
testScript,
envs,
response,
isolate,
context
),
(reason) => `Script execution failed: ${reason}`
),
TE.chain((result) =>
response: TestResponse,
experimentalScriptingSandbox = true
): TE.TaskEither<string, TestResult> => {
const responseObjHandle = preventCyclicObjects(response)
if (E.isLeft(responseObjHandle)) {
return TE.left(`Response marshalling failed: ${responseObjHandle.left}`)
}
if (!experimentalScriptingSandbox) {
return pipe(
TE.tryCatch(
async () => {
const isolate: ivmT.Isolate = new ivm.Isolate()
const context = await isolate.createContext()
return { isolate, context }
},
(reason) => `Context initialization failed: ${reason}`
),
TE.chain(({ isolate, context }) =>
pipe(
TE.tryCatch(
async () => {
await isolate.dispose()
return result
},
(disposeReason) => `Isolate disposal failed: ${disposeReason}`
async () =>
executeScriptInContext(
testScript,
envs,
response,
isolate,
context
),
(reason) => `Script execution failed: ${reason}`
),
TE.chain((result) =>
TE.tryCatch(
async () => {
await isolate.dispose()
return result
},
(disposeReason) => `Isolate disposal failed: ${disposeReason}`
)
)
)
)
)
}
return pipe(
TE.tryCatch(
async (): Promise<TestResult> => {
const testRunStack: TestDescriptor[] = [
{ descriptor: "root", expectResults: [], children: [] },
]
let finalEnvs = envs
let finalTestResults = testRunStack
const cage = await FaradayCage.create()
const result = await cage.runCode(testScript, [
pwPostRequestModule({
envs: cloneDeep(envs),
testRunStack: cloneDeep(testRunStack),
response,
handleSandboxResults: ({ envs, testRunStack }) => {
finalEnvs = envs
finalTestResults = testRunStack
},
}),
blobPolyfill,
ConsoleModule({
onLog(...args) {
console[args[0]](...args)
},
onCount(...args) {
console.count(args[0])
},
onTime(...args) {
console.timeEnd(args[0])
},
onTimeLog(...args) {
console.timeLog(...args)
},
onGroup(...args) {
console.group(...args)
},
onGroupEnd(...args) {
console.groupEnd(...args)
},
onClear(...args) {
console.clear(...args)
},
onAssert(...args) {
console.assert(...args)
},
onDir(...args) {
console.dir(...args)
},
onTable(...args) {
console.table(...args)
},
}),
crypto(),
esmModuleLoader,
fetch(),
])
if (result.type === "error") {
throw result.err
}
return {
tests: finalTestResults,
envs: finalEnvs,
}
},
(error) => {
if (error !== null && typeof error === "object" && "message" in error) {
const reason = `${"name" in error ? error.name : ""}: ${error.message}`
return `Script execution failed: ${reason}`
}
return `Script execution failed: ${String(error)}`
}
)
)
}
const executeScriptInContext = (
testScript: string,
envs: TestResult["envs"],

View file

@ -5,6 +5,7 @@ import { pipe } from "fp-ts/lib/function"
import { cloneDeep } from "lodash-es"
import {
Expectation,
GlobalEnvItem,
SelectedEnvItem,
TestDescriptor,
@ -83,7 +84,7 @@ const unsetEnv = (
}
// Compiles shared scripting API methods for use in both pre and post request scripts
const getSharedMethods = (envs: TestResult["envs"]) => {
export const getSharedMethods = (envs: TestResult["envs"]) => {
let updatedEnvs = envs
const envGetFn = (key: any) => {
@ -242,9 +243,7 @@ export const createExpectation = (
expectVal: any,
negated: boolean,
currTestStack: TestDescriptor[]
) => {
const result: Record<string, unknown> = {}
): Expectation => {
// Non-primitive values supplied are stringified in the isolate context
const resolvedExpectVal = getResolvedExpectValue(expectVal)
@ -445,14 +444,16 @@ export const createExpectation = (
return undefined
}
result.toBe = toBeFn
result.toBeLevel2xx = toBeLevel2xxFn
result.toBeLevel3xx = toBeLevel3xxFn
result.toBeLevel4xx = toBeLevel4xxFn
result.toBeLevel5xx = toBeLevel5xxFn
result.toBeType = toBeTypeFn
result.toHaveLength = toHaveLengthFn
result.toInclude = toIncludeFn
const result = {
toBe: toBeFn,
toBeLevel2xx: toBeLevel2xxFn,
toBeLevel3xx: toBeLevel3xxFn,
toBeLevel4xx: toBeLevel4xxFn,
toBeLevel5xx: toBeLevel5xxFn,
toBeType: toBeTypeFn,
toHaveLength: toHaveLengthFn,
toInclude: toIncludeFn,
} as Expectation
Object.defineProperties(result, {
not: {

View file

@ -1,3 +1,5 @@
import { ConsoleEntry } from "faraday-cage/modules"
/**
* The response object structure exposed to the test script
*/
@ -64,4 +66,23 @@ export type TestResult = {
export type GlobalEnvItem = TestResult["envs"]["global"][number]
export type SelectedEnvItem = TestResult["envs"]["selected"][number]
export type SandboxTestResult = TestResult & { tests: TestDescriptor }
export type SandboxTestResult = TestResult & { tests: TestDescriptor } & {
consoleEntries?: ConsoleEntry[]
}
export type SandboxPreRequestResult = {
envs: TestResult["envs"]
consoleEntries?: ConsoleEntry[]
}
export interface Expectation {
toBe(expectedVal: any): void
toBeLevel2xx(): void
toBeLevel3xx(): void
toBeLevel4xx(): void
toBeLevel5xx(): void
toBeType(expectedType: any): void
toHaveLength(expectedLength: any): void
toInclude(needle: any): void
readonly not: Expectation
}

View file

@ -1,24 +1,113 @@
import * as E from "fp-ts/Either"
import { TestResult } from "~/types"
import { SandboxPreRequestResult, TestResult } from "~/types"
import { FaradayCage } from "faraday-cage"
import {
blobPolyfill,
ConsoleEntry,
console as ConsoleModule,
crypto,
esmModuleLoader,
fetch,
} from "faraday-cage/modules"
import { cloneDeep } from "lodash"
import * as TE from "fp-ts/lib/TaskEither"
import { pwPreRequestModule } from "~/cage-modules/pw"
import Worker from "./worker?worker&inline"
export const runPreRequestScript = (
export const runPreRequestScript = async (
preRequestScript: string,
envs: TestResult["envs"]
): Promise<E.Either<string, TestResult["envs"]>> =>
new Promise((resolve) => {
const worker = new Worker()
envs: TestResult["envs"],
experimentalScriptingSandbox = true
): Promise<E.Either<string, SandboxPreRequestResult>> => {
const consoleEntries: ConsoleEntry[] = []
let finalEnvs = envs
// Listen for the results from the web worker
worker.addEventListener("message", (event: MessageEvent) =>
resolve(event.data.results)
)
if (!experimentalScriptingSandbox) {
return new Promise((resolve) => {
const worker = new Worker()
// Send the script to the web worker
worker.postMessage({
preRequestScript,
envs,
// Listen for the results from the web worker
worker.addEventListener("message", (event: MessageEvent) =>
resolve(event.data.results)
)
// Send the script to the web worker
worker.postMessage({
preRequestScript,
envs,
})
})
})
}
const cage = await FaradayCage.create()
const result = await cage.runCode(preRequestScript, [
pwPreRequestModule({
envs: cloneDeep(envs),
handleSandboxResults: ({ envs }) => {
finalEnvs = envs
},
}),
blobPolyfill,
ConsoleModule({
onLog(...args) {
console[args[0]](...args)
},
onCount(...args) {
console.count(args[0])
},
onTime(...args) {
console.timeEnd(args[0])
},
onTimeLog(...args) {
console.timeLog(...args)
},
onGroup(...args) {
console.group(...args)
},
onGroupEnd(...args) {
console.groupEnd(...args)
},
onClear(...args) {
console.clear(...args)
},
onAssert(...args) {
console.assert(...args)
},
onDir(...args) {
console.dir(...args)
},
onTable(...args) {
console.table(...args)
},
onFinish(entries) {
consoleEntries.push(...entries)
},
}),
crypto(),
esmModuleLoader,
fetch(),
esmModuleLoader,
])
if (result.type === "error") {
if (
result.err !== null &&
typeof result.err === "object" &&
"message" in result.err
) {
return TE.left(`Script execution failed: ${result.err.message}`)()
}
return TE.left(`Script execution failed: ${String(result.err)}`)()
}
return TE.right({
envs: finalEnvs,
consoleEntries,
})()
}

View file

@ -1,12 +1,12 @@
import * as TE from "fp-ts/TaskEither"
import { TestResult } from "~/types"
import { getPreRequestScriptMethods } from "~/shared-utils"
import { SandboxPreRequestResult, TestResult } from "~/types"
const executeScriptInContext = (
preRequestScript: string,
envs: TestResult["envs"]
): TE.TaskEither<string, TestResult["envs"]> => {
): TE.TaskEither<string, SandboxPreRequestResult> => {
try {
const { pw, updatedEnvs } = getPreRequestScriptMethods(envs)
@ -16,7 +16,9 @@ const executeScriptInContext = (
// Execute the script
executeScript(pw)
return TE.right(updatedEnvs)
return TE.right({
envs: updatedEnvs,
})
} catch (error) {
return TE.left(`Script execution failed: ${(error as Error).message}`)
}

View file

@ -1,27 +1,139 @@
import * as E from "fp-ts/Either"
import { SandboxTestResult, TestResponse, TestResult } from "~/types"
import {
SandboxTestResult,
TestDescriptor,
TestResponse,
TestResult,
} from "~/types"
import {
blobPolyfill,
ConsoleEntry,
console as ConsoleModule,
crypto,
esmModuleLoader,
fetch,
} from "faraday-cage/modules"
import { FaradayCage } from "faraday-cage"
import * as TE from "fp-ts/lib/TaskEither"
import { cloneDeep } from "lodash-es"
import { pwPostRequestModule } from "~/cage-modules/pw"
import { preventCyclicObjects } from "~/shared-utils"
import Worker from "./worker?worker&inline"
export const runTestScript = (
export const runTestScript = async (
testScript: string,
envs: TestResult["envs"],
response: TestResponse
response: TestResponse,
experimentalScriptingSandbox = true
): Promise<E.Either<string, SandboxTestResult>> => {
return new Promise((resolve) => {
const worker = new Worker()
const testRunStack: TestDescriptor[] = [
{ descriptor: "root", expectResults: [], children: [] },
]
// Listen for the results from the web worker
worker.addEventListener("message", (event: MessageEvent) =>
resolve(event.data.results)
)
let finalEnvs = envs
let finalTestResults = testRunStack
const consoleEntries: ConsoleEntry[] = []
// Send the script to the web worker
worker.postMessage({
testScript,
envs,
response,
const responseObjHandle = preventCyclicObjects(response)
if (E.isLeft(responseObjHandle)) {
return TE.left(`Response marshalling failed: ${responseObjHandle.left}`)()
}
if (!experimentalScriptingSandbox) {
return new Promise((resolve) => {
const worker = new Worker()
// Listen for the results from the web worker
worker.addEventListener("message", (event: MessageEvent) =>
resolve(event.data.results)
)
// Send the script to the web worker
worker.postMessage({
testScript,
envs,
response,
})
})
})
}
const cage = await FaradayCage.create()
const result = await cage.runCode(testScript, [
pwPostRequestModule({
envs: cloneDeep(envs),
testRunStack: cloneDeep(testRunStack),
response: responseObjHandle.right as TestResponse,
handleSandboxResults: ({ envs, testRunStack }) => {
finalEnvs = envs
finalTestResults = testRunStack
},
}),
blobPolyfill,
ConsoleModule({
onLog(...args) {
console[args[0]](...args.slice(1))
},
onCount(...args) {
console.count(args[0])
},
onTime(...args) {
console.timeEnd(args[0])
},
onTimeLog(...args) {
console.timeLog(...args)
},
onGroup(...args) {
console.group(...args)
},
onGroupEnd(...args) {
console.groupEnd(...args)
},
onClear(...args) {
console.clear(...args)
},
onAssert(...args) {
console.assert(...args)
},
onDir(...args) {
console.dir(...args)
},
onTable(...args) {
console.table(...args)
},
onFinish(entries) {
consoleEntries.push(...entries)
},
}),
crypto(),
esmModuleLoader,
fetch(),
])
if (result.type === "error") {
if (result.type === "error") {
if (
result.err !== null &&
typeof result.err === "object" &&
"message" in result.err
) {
return TE.left(`Script execution failed: ${result.err.message}`)()
}
return TE.left(`Script execution failed: ${String(result.err)}`)()
}
}
return TE.right(<SandboxTestResult>{
tests: finalTestResults[0],
envs: finalEnvs,
consoleEntries,
})()
}

View file

@ -1,11 +1,11 @@
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { SandboxTestResult, TestResponse, TestResult } from "~/types"
import {
getTestRunnerScriptMethods,
preventCyclicObjects,
} from "~/shared-utils"
import { SandboxTestResult, TestResponse, TestResult } from "~/types"
const executeScriptInContext = (
testScript: string,

View file

@ -43,6 +43,9 @@ export default defineConfig({
maxParallelFileOps: 2,
},
},
worker: {
format: "es",
},
resolve: {
alias: {
"tailwind.config.cjs": path.resolve(

View file

@ -721,6 +721,9 @@ importers:
vue-i18n:
specifier: 10.0.4
version: 10.0.4(vue@3.5.12(typescript@5.3.3))
vue-json-pretty:
specifier: 2.4.0
version: 2.4.0(vue@3.5.12(typescript@5.3.3))
vue-pdf-embed:
specifier: 2.1.0
version: 2.1.0(vue@3.5.12(typescript@5.3.3))
@ -1129,6 +1132,9 @@ importers:
'@types/lodash-es':
specifier: 4.17.12
version: 4.17.12
faraday-cage:
specifier: 0.0.15
version: 0.0.15
fp-ts:
specifier: 2.16.9
version: 2.16.9
@ -4919,6 +4925,24 @@ packages:
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jitl/quickjs-ffi-types@0.31.0':
resolution: {integrity: sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw==}
'@jitl/quickjs-singlefile-mjs-release-asyncify@0.31.0':
resolution: {integrity: sha512-usrttM3cCk6lrykUp1jdRI3I3j5zDZlJsq7QjHeJbcujqhqJriPH1GKjEWZuQr+LU53gUw6ZkX4z00TC04yXSw==}
'@jitl/quickjs-wasmfile-debug-asyncify@0.31.0':
resolution: {integrity: sha512-YkdzQdr1uaftFhgEnTRjTTZHk2SFZdpWO7XhOmRVbi6CEVsH9g5oNF8Ta1q3OuSJHRwwT8YsuR1YzEiEIJEk6w==}
'@jitl/quickjs-wasmfile-debug-sync@0.31.0':
resolution: {integrity: sha512-8XvloaaWBONqcHXYs5tWOjdhQVxzULilIfB2hvZfS6S+fI4m2+lFiwQy7xeP8ExHmiZ7D8gZGChNkdLgjGfknw==}
'@jitl/quickjs-wasmfile-release-asyncify@0.31.0':
resolution: {integrity: sha512-uz0BbQYTxNsFkvkurd7vk2dOg57ElTBLCuvNtRl4rgrtbC++NIndD5qv2+AXb6yXDD3Uy1O2PCwmoaH0eXgEOg==}
'@jitl/quickjs-wasmfile-release-sync@0.31.0':
resolution: {integrity: sha512-hYduecOByj9AsAfsJhZh5nA6exokmuFC8cls39+lYmTCGY51bgjJJJwReEu7Ff7vBWaQCL6TeDdVlnp2WYz0jw==}
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'}
@ -8835,6 +8859,9 @@ packages:
resolution: {integrity: sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==}
engines: {node: ^12.20 || >= 14.13}
faraday-cage@0.0.15:
resolution: {integrity: sha512-ZERKHFsea2fZ0gfDVFOkXmtryhp+7CyUBFaa9nvRq7Gbf6XVnZ0f0dKsZjwoBzYqSD3Lhl2DFfK8bW4PEo1y0g==}
fast-decode-uri-component@1.0.1:
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
@ -11959,6 +11986,13 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quickjs-emscripten-core@0.31.0:
resolution: {integrity: sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ==}
quickjs-emscripten@0.31.0:
resolution: {integrity: sha512-K7Yt78aRPLjPcqv3fIuLW1jW3pvwO21B9pmFOolsjM/57ZhdVXBr51GqJpalgBlkPu9foAvhEAuuQPnvIGvLvQ==}
engines: {node: '>=16.0.0'}
quicktype-core@23.0.170:
resolution: {integrity: sha512-ZsjveG0yJUIijUx4yQshzyQ5EAXKbFSBTQJHnJ+KoSZVxcS+m3GcmDpzrdUIRYMhgLaF11ZGvLSYi5U0xcwemw==}
@ -13980,6 +14014,12 @@ packages:
peerDependencies:
vue: 3.5.12
vue-json-pretty@2.4.0:
resolution: {integrity: sha512-e9bP41DYYIc2tWaB6KuwqFJq5odZ8/GkE6vHQuGcbPn37kGk4a3n1RNw3ZYeDrl66NWXgTlOfS+M6NKkowmkWw==}
engines: {node: '>= 10.0.0', npm: '>= 5.0.0'}
peerDependencies:
vue: 3.5.12
vue-pdf-embed@2.1.0:
resolution: {integrity: sha512-yEtitm2q1jY2pmgnbwDILhm8cEby01WhfZJCMKvhKwdorwc92FDefIDZ5yf0ixu4t+WHMfc1+Zz4OGsoW/PRag==}
peerDependencies:
@ -19275,6 +19315,28 @@ snapshots:
'@types/yargs': 17.0.33
chalk: 4.1.2
'@jitl/quickjs-ffi-types@0.31.0': {}
'@jitl/quickjs-singlefile-mjs-release-asyncify@0.31.0':
dependencies:
'@jitl/quickjs-ffi-types': 0.31.0
'@jitl/quickjs-wasmfile-debug-asyncify@0.31.0':
dependencies:
'@jitl/quickjs-ffi-types': 0.31.0
'@jitl/quickjs-wasmfile-debug-sync@0.31.0':
dependencies:
'@jitl/quickjs-ffi-types': 0.31.0
'@jitl/quickjs-wasmfile-release-asyncify@0.31.0':
dependencies:
'@jitl/quickjs-ffi-types': 0.31.0
'@jitl/quickjs-wasmfile-release-sync@0.31.0':
dependencies:
'@jitl/quickjs-ffi-types': 0.31.0
'@jridgewell/gen-mapping@0.3.5':
dependencies:
'@jridgewell/set-array': 1.2.1
@ -24377,6 +24439,12 @@ snapshots:
extract-files@11.0.0: {}
faraday-cage@0.0.15:
dependencies:
'@jitl/quickjs-ffi-types': 0.31.0
'@jitl/quickjs-singlefile-mjs-release-asyncify': 0.31.0
quickjs-emscripten: 0.31.0
fast-decode-uri-component@1.0.1: {}
fast-deep-equal@3.1.3: {}
@ -28421,6 +28489,18 @@ snapshots:
queue-microtask@1.2.3: {}
quickjs-emscripten-core@0.31.0:
dependencies:
'@jitl/quickjs-ffi-types': 0.31.0
quickjs-emscripten@0.31.0:
dependencies:
'@jitl/quickjs-wasmfile-debug-asyncify': 0.31.0
'@jitl/quickjs-wasmfile-debug-sync': 0.31.0
'@jitl/quickjs-wasmfile-release-asyncify': 0.31.0
'@jitl/quickjs-wasmfile-release-sync': 0.31.0
quickjs-emscripten-core: 0.31.0
quicktype-core@23.0.170:
dependencies:
'@glideapps/ts-necessities': 2.2.3
@ -31283,6 +31363,10 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.12(typescript@5.6.3)
vue-json-pretty@2.4.0(vue@3.5.12(typescript@5.3.3)):
dependencies:
vue: 3.5.12(typescript@5.3.3)
vue-pdf-embed@2.1.0(vue@3.5.12(typescript@5.3.3)):
dependencies:
pdfjs-dist: 4.7.76