From e2b9b11977d24dac7a6b988b9ff83629b1a65ca8 Mon Sep 17 00:00:00 2001 From: Jupegarnica Date: Fri, 28 Oct 2022 16:53:51 +0200 Subject: [PATCH] 1.0 --- .env | 3 + .env.example | 3 + .github/workflows/readme.yml | 19 ++ .vscode/settings.json | 4 + Dockerfile | 6 + README.md | 202 +++++++++++++++++ ROADMAP.md | 29 +++ VERSION | 1 + compose.yml | 34 +++ deno.jsonc | 19 ++ http/assert.http | 57 +++++ http/example.http | 63 ++++++ http/failFast.http | 35 +++ http/faker.http | 64 ++++++ http/globalVars.http | 25 +++ http/graphql.http | 24 ++ http/host.http | 32 +++ http/host2.http | 9 + http/http2.http | 5 + http/import.http | 17 ++ http/interpolate.http | 105 +++++++++ http/only.http | 23 ++ http/parser.http | 35 +++ http/pass.http | 14 ++ http/redirect.http | 18 ++ http/ref.http | 23 ++ http/ref.loop.http | 24 ++ http/test1.http | 32 +++ http/test2.http | 16 ++ http/timeout.http | 27 +++ src/assertResponse.ts | 65 ++++++ src/cli.ts | 199 ++++++++++++++++ src/fetchBlock.ts | 113 ++++++++++ src/files.ts | 80 +++++++ src/help.ts | 288 ++++++++++++++++++++++++ src/highlight.ts | 34 +++ src/mimes.ts | 60 +++++ src/parseBlockText.ts | 228 +++++++++++++++++++ src/print.ts | 306 +++++++++++++++++++++++++ src/runner.ts | 342 ++++++++++++++++++++++++++++ src/types.ts | 140 ++++++++++++ test/assertResponse.test.ts | 134 +++++++++++ test/e2e.test.ts | 97 ++++++++ test/fetchBlock.test.ts | 173 ++++++++++++++ test/filePathsToFiles.test.ts | 22 ++ test/fileTextToBlocks.test.ts | 31 +++ test/globsToFiles.test.ts | 29 +++ test/parseBlockText.test.ts | 412 ++++++++++++++++++++++++++++++++++ test/runner.test.ts | 279 +++++++++++++++++++++++ 49 files changed, 4000 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 .github/workflows/readme.yml create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 VERSION create mode 100644 compose.yml create mode 100644 deno.jsonc create mode 100644 http/assert.http create mode 100644 http/example.http create mode 100644 http/failFast.http create mode 100644 http/faker.http create mode 100644 http/globalVars.http create mode 100644 http/graphql.http create mode 100644 http/host.http create mode 100644 http/host2.http create mode 100644 http/http2.http create mode 100644 http/import.http create mode 100644 http/interpolate.http create mode 100644 http/only.http create mode 100644 http/parser.http create mode 100644 http/pass.http create mode 100644 http/redirect.http create mode 100644 http/ref.http create mode 100644 http/ref.loop.http create mode 100644 http/test1.http create mode 100644 http/test2.http create mode 100644 http/timeout.http create mode 100644 src/assertResponse.ts create mode 100644 src/cli.ts create mode 100644 src/fetchBlock.ts create mode 100644 src/files.ts create mode 100644 src/help.ts create mode 100644 src/highlight.ts create mode 100644 src/mimes.ts create mode 100644 src/parseBlockText.ts create mode 100644 src/print.ts create mode 100644 src/runner.ts create mode 100644 src/types.ts create mode 100644 test/assertResponse.test.ts create mode 100644 test/e2e.test.ts create mode 100644 test/fetchBlock.test.ts create mode 100644 test/filePathsToFiles.test.ts create mode 100644 test/fileTextToBlocks.test.ts create mode 100644 test/globsToFiles.test.ts create mode 100644 test/parseBlockText.test.ts create mode 100644 test/runner.test.ts diff --git a/.env b/.env new file mode 100644 index 0000000..080a1db --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +HOST= https://faker.deno.dev +HOST_HTTPBIN= https://httpbin.org +HOST_HTTP2= https://http2.deno.dev \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c72d0c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +HOST= https://aker.deno.dev +HOST_HTTPBIN= https://httpbin.org +HOST_HTTP2= https://http2.deno.dev \ No newline at end of file diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml new file mode 100644 index 0000000..f5787c8 --- /dev/null +++ b/.github/workflows/readme.yml @@ -0,0 +1,19 @@ +## create readme on changes to src/cli.ts +name: readme +on: + workflow_dispatch: + push: + paths: + - src/help.ts +jobs: + + readme: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: denoland/setup-deno@v1 + - run: deno task readme + - uses: EndBug/add-and-commit@v9 + with: + message: "docs: update readme" + add: "README.md" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..aa1c94e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "deno.enable": true, + "deno.unstable": true +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e350c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM denoland/deno +WORKDIR /app +COPY . . +RUN deno cache --unstable --lock=lock.json --lock-write src/cli.ts + +CMD run --allow-net --allow-read --allow-write --allow-env --unstable src/cli.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d33f1f --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +``` +------------------------- +--------- TEPI ---------- +------------------------- +-- A .http Test Runner -- +------------------------- +``` + +Test your HTTP APIs with standard http syntax + +## Features: + +- 📝 Write end to end API REST tests in `.http` files +- 🔎 Validate Response status, headers and/or body. +- 🔥 Interpolate javascript with eta template `<%= %>` +- 🖊 Write metadata as frontmatter yaml +- 📦 Reference by name another test to run them in advance +- ⏱ Set a timeout for each test or globally in milliseconds. After the timeout, + the test will fail. +- 🚨 Stop running tests after the first failure. +- 🔋 Use env files to load environment variables +- 😎 Fully featured and colorful display modes. (none, minimal, default and full) +- 👁 Watch files for changes and rerun tests. +- 🍯 Standard Response and Request with a automatic getBody() + +## Install: + +```bash +deno install --unstable --allow-read --allow-env --allow-net -f -n tepi https://deno.land/x/tepi/src/cli.ts +``` + +Or run remotely width: + +```bash +deno run --unstable --allow-read --allow-env --allow-net https://deno.land/x/tepi/src/cli.ts +``` + +## Usage: + +tepi [OPTIONS] [FILES|GLOBS...] + +## Options: + +- -w `--watch` Watch files for changes and rerun tests. +- -t `--timeout` Set the timeout for each test in milliseconds. After the + timeout, the test will fail. +- -f `--fail-fast` Stop running tests after the first failure. +- -d `--display` Set the display mode. (none, minimal, default and full) * none: + display nothing * minimal: display only a minimal summary * default: list + results and full error summary * full: display also all HTTP requests and + responses * verbose: display also all metadata and not truncate data +- -h `--help` output usage information +- -e `--env-file` load environment variables from a .env file +- `--no-color` output without color +- `--upgrade` upgrade to the latest version + +## Examples: + +`tepi` + +> Run all .http in the current directory and folders. (same as tepi ./**/*.http) + +`tepi test.http ./test2.http` + +> Run test.http and test2.http + +`tepi **/*.http` + +> Run all .http in the current directory and folders. + +`tepi rest.http --watch` + +> Run rest.http and rerun when it changes + +`tepi rest.http --watch "src/**/*.ts"` + +> Run rest.http and rerun when any .ts file in the src folder changes. + +`tepi rest.http --watch "src/**/*.json" --watch "src/**/*.ts"` + +> You can use multiple --watch flags. Note: You can use globs here too, but use +> quotes to avoid the shell expanding them. + +`tepi --timeout 10000` + +> Set the timeout for each test in milliseconds. After the timeout, the test +> will fail. + +`tepi --fail-fast` + +> Stop running tests after the first failure. + +`tepi --display minimal` + +> Set the display mode. (none, minimal, default and full) + +`tepi --env-file .env --env-file .env.test` + +> Load environment variables from a .env and .env.test + +## HTTP syntax: + +- You can use the standard HTTP syntax in your .http files to run a request and + response validation. +- Use the `###` to separate the requests. +- Use frontmatter yaml to set metadata. + +For example, validate the headers, status code, status text and body: + +``` +GET https://faker.deno.dev/?body=hola&status=400 + +HTTP/1.1 400 Bad Request +content-type: text/plain; charset=utf-8 + +hola + +# +### +``` + +## Interpolation: + +It's deno 🔥 + +Uses eta as template engine, see docs: https://deno.land/x/eta + +Use `<%= %>` to interpolate values. + +All the std assertion module is available: +https://deno.land/std/testing/asserts.ts + +Use `<% %>` to run custom assertions or custom JS. For example: + +``` +GET http://localhost:3000/users + +<% assert(response.status === 200) %> +``` + +Or: + +``` +<% if (Math.random() > 0.5) { %> + GET http://localhost:3000/users/1 +<% } else { %> + GET http://localhost:3000/users/2 +<% } %> +``` + +### Interpolation scope: + +In the Interpolation `<%= %>` or `<% %>` you have access to any Deno API and the +following variables: + +> request: The Request from the actual block. meta: The metadata from the actual +> block. and the frontmatter global metadata. response: The standard Response +> object from the fetch API from the actual request. (only available in the +> expected response, after the request) body: The extracted body an alias of +> `await response.getBody()` (only available in the expected response, after the +> request) + +> [name]: the named block already run for example: `<%= loginTest.body.jwt %>` +> or `<%= loginTest.response.status %>` + +The Block signature is: + +```ts +type Block = { + meta: { + [key: string]: any; + }; + request?: Request; + response?: Response; + expectedResponse?: Response; + error?: Error; + body?: any; +}; +``` + +For example: + +``` +--- +name: login +--- +POST https://example.com/login +Content-Type: application/json + +{"user": "Garn", "password": "1234"} + +HTTP/1.1 200 OK + +### +--- +needs: login +# not really needed, because the requests run in order of delcaration +--- +GET https://example.com/onlyAdmin +Authorization: Bearer <%= loginTest.body.jwt %> +Content-Type: application/json +``` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a50b911 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,29 @@ +# Roadmap + +This document describes the roadmap for the project. + +## 1.0.0 + +- [x] Support for the `--watch` +- [x] Support for the `--timeout` +- [x] Support for the `--fail-fast` +- [x] `--display` none, minimal, default , full and verbose +- [x] `--help` output usage information +- [x] `--env-file` load environment variables from a .env file +- [x] `--no-color` output without color +- [x] `--upgrade` upgrade to the latest version +- [x] Support front-matter yaml as metadata, global and per test +- [x] Support meta.host for declaring the base url. +- [x] Support meta.needs for running tests in a specific order. +- [x] Support meta.ignore for skipping tests. +- [x] Support meta.only for running only specific tests. +- [x] Support for eta as template engine. +- [x] Support for meta.display to override the global display mode. +- [x] Support for meta.timeout to override the global timeout. +- [x] Support for meta `import:` to import other files + +- [ ] Support for meta.noColor to override the global noColor. +- [ ] Better error display. +- [ ] Support for the `--watch-no-clear` +- [ ] Support for meta `run:` to run shell commands +- [ ] Concurrent test execution diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..758a46e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.15 \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..01a2057 --- /dev/null +++ b/compose.yml @@ -0,0 +1,34 @@ +services: + faker: + image: jupegarnica/faker.deno.dev + ports: + - 80:8000 + networks: + - tepi-net + httpbin: + image: kennethreitz/httpbin + ports: + - "81:80" + networks: + - tepi-net + + test: + networks: + - tepi-net + + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/app + environment: + - HOST=http://faker:8000 + - HOST_HTTPBIN=http://httpbin + command: deno task test + depends_on: + - faker + - httpbin + +networks: + tepi-net: + driver: bridge \ No newline at end of file diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..eeee280 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,19 @@ +{ + "tasks": { + "dev": "TEPI_NOT_EXIT=1 deno run -A --unstable --watch ./src/cli.ts", + // test + "test": "NO_LOG=1 deno test -A --unstable", + "test-watch": "FORCE_COLOR=1 deno test -A --unstable --watch test", + "local": "HOST=http://localhost HOST_HTTPBIN=http://localhost:81 deno task test-watch", + // dev + "install": "deno install -fA --unstable --name tepi ./src/cli.ts", + "readme": "NO_COLOR=1 deno run -A --unstable ./src/cli.ts --help > README.md", + "help": " deno run -A --unstable --watch ./src/cli.ts --help", + // chore + "udd": "deno run -A --reload https://deno.land/x/udd/main.ts --test='deno task test' 'src/**/*.ts' 'test/**/*.ts'", + // release + "dnt": "deno run -A https://deno.land/x/dnt_prompt/main.ts", + "version": "deno run -A https://deno.land/x/version/index.ts", + "release": "deno task version patch && git push --tags origin main" + } +} diff --git a/http/assert.http b/http/assert.http new file mode 100644 index 0000000..c094e3c --- /dev/null +++ b/http/assert.http @@ -0,0 +1,57 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- +### + +POST /pong?quiet=true +Content-Type: application/json +x-powered-by: Deno + +{ "message":"pong", "quiet":true } + +HTTP/1.1 200 OK +Content-Type: application/json +x-powered-by: Deno + + +{ + "message":"pong", + "quiet": false + } + +### + +POST /pong?quiet=true + +hello + +HTTP/1.1 200 OK + +hello +<% assertEquals(await response.getBody(), "hello", 'not body extracted') %> + + +### +--- +name: must fail with custom error +--- +POST /pong?quiet=true + +hello + +HTTP/1.1 200 OK + +hello +<% assertEquals(await response.getBody(), "hola", 'failed!') %> + + + +### +--- +name: must fail because of headers +--- +GET /pong?quiet=true +x-quiet: true + +HTTP/1.1 200 OK +x-quiet: false diff --git a/http/example.http b/http/example.http new file mode 100644 index 0000000..841a1fd --- /dev/null +++ b/http/example.http @@ -0,0 +1,63 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +explanation: If the first block does not have a request its metadata will be use globally for all requests. +--- +### +--- +needs: loginTest +name: depends on login +--- +POST https://example.com/onlyAdmin +Authorization: Bearer <%= loginTest.body.jwt %> +Content-Type: application/json + +{"name": "Garn"} + +# write the expected response to validate the actual response +HTTP/1.1 403 Forbidden + +### requests separator +--- +name: optional name +timeout: 500 # must respond in less than 500ms +--- +GET /?body=hola&status=400 +host: https://faker.deno.dev + +### +--- +name: name +--- +GET https://example.com + + +HTTP/1.1 200 OK +Content-Type: text/html; charset=UTF-8 + + +### +--- +# display: full +redirect: follow +description: | + you can pass the requestInit has metadata: fetch api doc: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters +--- +GET /image/avatar +quiet: true + +### +--- +description: test the response body json declaring an expected response +name: MUST FAIL because the response it not has all the keys. +--- +GET https://httpbin.org/json + +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "slideshow": { + "title": "Sample Slide Show" + }, + "unexpected": "this should not be here" +} diff --git a/http/failFast.http b/http/failFast.http new file mode 100644 index 0000000..202a222 --- /dev/null +++ b/http/failFast.http @@ -0,0 +1,35 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- +### +--- +name: ignored +ignore: true +--- +POST /pong?quiet=true&status=400 +content-type: application/json + +{ + "name": "pong asd", + "verbose": true +} + +HTTP/1.1 203 OK + + +### +--- +name: invalid url +--- +POST https://invalid + +HTTP/1.1 201 OK + + +### +--- +name: invalid url2 +--- +POST https://invalid2 + +HTTP/1.1 201 OK diff --git a/http/faker.http b/http/faker.http new file mode 100644 index 0000000..f919da1 --- /dev/null +++ b/http/faker.http @@ -0,0 +1,64 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- + +### +--- +name: json +--- +POST /pong?quiet=true +content-type: application/json + +{ + "name": "pong asd" +} + +HTTP/1.1 200 OK + + +### +--- +name: xml +--- +POST /pong +log-level: critical +host: faker.deno.dev +content-type: application/xml + + + pong asd + true + + +HTTP/1.1 201 OK + + + +### +--- +name: yaml +--- + +POST /pong +x-quiet: true +Content-Type: application/x-yaml + +name: pong asd +list: + - 1 + - 2 + - 3 +obj: {a: 1, b: 2} + + + +HTTP/1.1 200 OK + + +### +--- +redirect: manual +name: redirect +--- +GET /image/avatar +log-level: critical diff --git a/http/globalVars.http b/http/globalVars.http new file mode 100644 index 0000000..a4279aa --- /dev/null +++ b/http/globalVars.http @@ -0,0 +1,25 @@ +--- +host: <%= Deno.env.get("HOST") || "https://faker.deno.dev" %> +<% globalThis.a = 2; %> +<% let a = 1; %> +--- + +### +--- +name: dos +--- +POST /?body=<%= globalThis.a %> +quiet: true + +HTTP/1.1 200 + +2 +### + + +POST /?body=2 +quiet: true + +HTTP/1.1 200 + +<%= dos.body %> diff --git a/http/graphql.http b/http/graphql.http new file mode 100644 index 0000000..8db55d8 --- /dev/null +++ b/http/graphql.http @@ -0,0 +1,24 @@ + +POST https://rickandmortyapi.com/graphql +Content-Type: application/json + +query Query { + characters(page: 2, filter: {name: "Morty"}) { + info { + count + } + results { + name + } + } + location(id: 1) { + id + } + episodesByIds(ids: [1, 2]) { + id + } +} + +HTTP/1.1 400 Bad Request + +body \ No newline at end of file diff --git a/http/host.http b/http/host.http new file mode 100644 index 0000000..3fe1d34 --- /dev/null +++ b/http/host.http @@ -0,0 +1,32 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +hostHttpbin: <%= Deno.env.get('HOST_HTTPBIN') || 'http://httpbin.org' %> +version: 0.1.0 +--- + +### +--- +host: <%= (Deno.env.get('HOST') || 'https://faker.deno.dev') + '/pong' %> +--- +GET ?quiet=true + + +### + +GET / +quiet: true + +### + +GET /ping +quiet: true + +### + +GET /get +host: <%= meta.hostHttpbin %> + +### + +GET /post +host: <%= meta.hostHttpbin.replace('http://','') %> \ No newline at end of file diff --git a/http/host2.http b/http/host2.http new file mode 100644 index 0000000..7cc024e --- /dev/null +++ b/http/host2.http @@ -0,0 +1,9 @@ +--- +host: <%= Deno.env.get('HOST_HTTPBIN') || 'http://httpbin.org' %> +--- + +### +--- +--- +GET /get +<% assert(meta.host, "http://httpbin.org" ) %> diff --git a/http/http2.http b/http/http2.http new file mode 100644 index 0000000..e25fd84 --- /dev/null +++ b/http/http2.http @@ -0,0 +1,5 @@ + +# TODO: are there a way of knowing the http protocol used? +POST https://http2.deno.dev/ HTTP/2 + +body \ No newline at end of file diff --git a/http/import.http b/http/import.http new file mode 100644 index 0000000..4d8fc19 --- /dev/null +++ b/http/import.http @@ -0,0 +1,17 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +# import: ../http/failFast.http +--- + +### +--- +import: ./pass.http +name: needPass.http +# needs: passId +--- +GET /pong?body=<%= passId.body %> +quiet: true + +HTTP/1.1 200 OK + +passed \ No newline at end of file diff --git a/http/interpolate.http b/http/interpolate.http new file mode 100644 index 0000000..17ddf49 --- /dev/null +++ b/http/interpolate.http @@ -0,0 +1,105 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- +### +--- +data: Garn +name: hasData +--- +POST /pong?quiet=true +x-data: <%= meta.data %> + +Hola <%= meta.data %>! + +HTTP/1.1 200 OK +content-type: <%= request.headers.get("content-type") %> + +Hola Garn! +<% false && console.log('hola mundo') %> + +### +GET /pong?quiet=true +read-from-name: <%= hasData.meta.data %> + +### +--- +name: must fail +--- +GET /pong +quiet: true + +<% assert(false, 'ups') %> + + +### +--- +name: must work +--- +POST /pong?quiet=true + +hola + +HTTP/1.1 200 OK + +<%= 'hola' %> + +<% assert(true, 'ups') %> + + +### +--- +name: must read another request +--- +POST /pong +quiet: true +x-payload: <%= hasData.request.headers.get('x-data') + '?' %> + +<%= hasData.request.headers.get('x-data') + '!' %> + +HTTP/1.1 200 OK +x-payload: Garn? + +Garn! + + +### +--- +name: must read same request +--- +POST /pong +quiet: true + +body + +HTTP/1.1 200 OK + +<%= await request.getBody() %> + + +### +--- +name: must read same response +--- +POST /pong +quiet: true +adios: mundo + +body ¿? + +HTTP/1.1 <%= response.status %> <%= response.statusText %> +hola: mundo +<% response.headers.forEach((v, k) => { %> <%= k %>: <%= v +'\n'%> <% }) %> + +<%= await response.getBody() + '!' %> + + + +### +--- +name: must interpolate ts +--- +POST /pong?quiet=true +x-quiet: true + +<% const a = 1; %> +<%= a %> diff --git a/http/only.http b/http/only.http new file mode 100644 index 0000000..35b7d48 --- /dev/null +++ b/http/only.http @@ -0,0 +1,23 @@ +--- +host: <%= Deno.env.get("HOST") || "https://faker.deno.dev "%> +--- +### +--- +name: 204 No Content +--- +GET /pong?status=204 +quiet: true + +HTTP/1.1 204 No Content + +### +--- +name: 203 Non-Authoritative Information +only: <%= !!Deno.env.get('TEST_ONLY') %> +--- + +GET /pong?status=203 +quiet: true + +HTTP/1.1 203 Non Authoritative Information +### \ No newline at end of file diff --git a/http/parser.http b/http/parser.http new file mode 100644 index 0000000..f4d14d1 --- /dev/null +++ b/http/parser.http @@ -0,0 +1,35 @@ + +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- +### + +--- +name: must fail parsing request +--- +GET /pong +quiet: true + +<% throw new Error('parse request fail') %> + +### +--- +name: must fail parsing response +--- +GET /pong +quiet: true + +HTTP/1.1 200 OK + +<% assert(false, 'parse response fail') %> + +### +--- +meta <% assert(false, 'parse meta fail') %> +name: must fail parsing meta +--- + +GET /pong +quiet: true + +HTTP/1.1 200 OK \ No newline at end of file diff --git a/http/pass.http b/http/pass.http new file mode 100644 index 0000000..3bfb357 --- /dev/null +++ b/http/pass.http @@ -0,0 +1,14 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- + +### +--- +name: passId +--- +GET /pong?body=passed +quiet: true + +HTTP/1.1 200 OK + +passed \ No newline at end of file diff --git a/http/redirect.http b/http/redirect.http new file mode 100644 index 0000000..88b7921 --- /dev/null +++ b/http/redirect.http @@ -0,0 +1,18 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- +### +--- +redirect: follow +--- +GET /image/avatar +x-quiet: true + + +### + +--- +redirect: manual +--- +GET /image/avatar +x-quiet: true diff --git a/http/ref.http b/http/ref.http new file mode 100644 index 0000000..b3ffaee --- /dev/null +++ b/http/ref.http @@ -0,0 +1,23 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- +### + +--- +name: block1 +needs: block2 +--- +POST /pong +x-quiet: true + +<%= await block2.response.getBody() + '?' %> + +### +--- +name: block2 +description: must not enter on infinite loop +--- +POST /pong +x-quiet: true + +RESPONSE! \ No newline at end of file diff --git a/http/ref.loop.http b/http/ref.loop.http new file mode 100644 index 0000000..eadb16a --- /dev/null +++ b/http/ref.loop.http @@ -0,0 +1,24 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- +### + +--- +name: block1 +needs: block2 +--- +POST /pong +x-quiet: true + +<%= block2.meta?.name + '?' %> + +### +--- +name: block2 +needs: block1 +description: must not enter on infinite loop +--- +POST /pong +x-quiet: true + +<%= block1.meta?.name + '??' %> diff --git a/http/test1.http b/http/test1.http new file mode 100644 index 0000000..71de234 --- /dev/null +++ b/http/test1.http @@ -0,0 +1,32 @@ +--- +host: <%= Deno.env.get('HOST_HTTPBIN') || 'https://httpbin.org' %> +--- +### + +GET /html + +### + +GET /status/204 + +### +GET /gzip + +### +POST /status/400 +Content-Type: text/plain + +must throw + +HTTP/1.1 200 OK + +### +POST /anything +Content-Type: text/plain + +hola mundo + +HTTP/1.1 200 OK +Content-Type: application/json + +["hello"] diff --git a/http/test2.http b/http/test2.http new file mode 100644 index 0000000..79e6443 --- /dev/null +++ b/http/test2.http @@ -0,0 +1,16 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- +### +--- +boolean: true +--- +POST /pong?quiet=true&delay=0 +Content-Type: text/plain + +hola mundo + +HTTP/1.1 200 OK +Content-Type: text/plain + +hola mundo diff --git a/http/timeout.http b/http/timeout.http new file mode 100644 index 0000000..d729e03 --- /dev/null +++ b/http/timeout.http @@ -0,0 +1,27 @@ +--- +host: <%= Deno.env.get('HOST') || 'https://faker.deno.dev' %> +--- +### +--- +timeout: 100 +--- +GET /pong?delay=200 +x-quiet: true + +HTTP/1.1 200 OK + +### +--- +name: force no timeout +timeout: 0 +--- +GET /pong?delay=200 +x-quiet: true + + +### +--- +name: no timeout +--- +GET /pong?delay=200 +x-quiet: true diff --git a/src/assertResponse.ts b/src/assertResponse.ts new file mode 100644 index 0000000..7c3b444 --- /dev/null +++ b/src/assertResponse.ts @@ -0,0 +1,65 @@ +import { _Response, Block } from "./types.ts"; +import { + assertEquals, + assertObjectMatch, +} from "https://deno.land/std@0.160.0/testing/asserts.ts"; + +export class ExpectedResponseError extends Error { + constructor(message: string) { + super(message); + this.name = "ExpectedResponseError"; + } +} + +export async function assertResponse(block: Omit) { + const { expectedResponse, actualResponse } = block; + if (!expectedResponse) { + throw new ExpectedResponseError("block.expectedResponse is undefined"); + } + if (!actualResponse) { + throw new ExpectedResponseError("block.actualResponse is undefined"); + } + + if (expectedResponse.status) { + try { + assertEquals(expectedResponse.status, actualResponse.status); + } catch (error) { + throw new ExpectedResponseError(`Status code mismatch\n${error.message}`); + } + } + if (expectedResponse.statusText) { + try { + assertEquals(expectedResponse.statusText, actualResponse.statusText); + } catch (error) { + throw new ExpectedResponseError(`Status text mismatch\n${error.message}`); + } + } + + if (await expectedResponse.getBody()) { + let assertBody: typeof assertEquals | typeof assertObjectMatch = + assertEquals; + if ( + typeof await expectedResponse.getBody() === "object" && + typeof await actualResponse.getBody() === "object" + ) { + assertBody = assertObjectMatch; + } + try { + assertBody( + await actualResponse.getBody() as Record, + await expectedResponse.getBody() as Record, + ); + } catch (error) { + throw new ExpectedResponseError(`Body mismatch\n${error.message}`); + } + } + if (expectedResponse.headers) { + try { + for (const [key, value] of expectedResponse.headers.entries()) { + assertEquals(actualResponse.headers.get(key), value); + } + } catch (error) { + throw new ExpectedResponseError(`Header mismatch\n${error.message}`); + } + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..c675e32 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,199 @@ +import { type Args, parse } from "https://deno.land/std@0.160.0/flags/mod.ts"; +import type { Meta } from "./types.ts"; +import * as fmt from "https://deno.land/std@0.160.0/fmt/colors.ts"; +// import ora from "npm:ora"; +import { relative } from "https://deno.land/std@0.160.0/path/posix.ts"; +import { globsToFilePaths } from "./files.ts"; +import { config } from "https://deno.land/std@0.160.0/dotenv/mod.ts"; +import { runner } from "./runner.ts"; +import { DISPLAYS, getDisplayIndex } from "./print.ts"; +import { help } from "./help.ts"; + +const mustExit = !Deno.env.get("TEPI_NOT_EXIT"); +function exit(code: number) { mustExit && Deno.exit(code); } + +if (import.meta.main) { + await cli(); +} + +async function cli() { + const options = { + default: { + display: "default", + help: false, + }, + collect: ["watch", "envFile"], + boolean: ["help", "failFast", "noColor", "upgrade"], + string: ["display", "envFile"], + + alias: { + h: "help", + w: "watch", + t: "timeout", + f: "failFast", + d: "display", + e: "envFile", + envFile: "env-file", + noColor: "no-color", + failFast: "fail-fast", + }, + }; + const args: Args = parse(Deno.args, options); + + // --no-color + ///////////// + if (args.noColor) { + fmt.setColorEnabled(false); + } + if (args.upgrade) { + const { code } = await Deno.spawn(Deno.execPath(), { + args: + "install --unstable --allow-read --allow-env --allow-net --reload -f -n tepi https://deno.land/x/tepi/src/cli.ts" + .split(" "), + stdout: "inherit", + stderr: "inherit", + }); + exit(code); + } + + // --help + ///////////// + if (args.help) { + help(); + return; + } + // --display + ///////////// + + if (args.display === "") { + args.display = "full"; + } + + const defaultMeta: Meta = { + timeout: 0, + display: args.display as string, + }; + if (getDisplayIndex(defaultMeta) === Infinity) { + console.error( + fmt.brightRed( + `Invalid display mode ${args.display}\n Must be one of: ${DISPLAYS.map((t) => fmt.bold(t)).join(", ") + }`, + ), + ); + exit(1); + } + // --env-file + ///////////// + const keysLoaded = new Set(); + const envFiles = new Set(); + for (const path of args.envFile) { + const vars = await config({ + export: true, + path, + safe: true, + allowEmptyValues: true, + }); + for (const key in vars) { + keysLoaded.add(key); + envFiles.add(path); + } + } + if (keysLoaded.size && getDisplayIndex(defaultMeta) > 0) { + console.info( + fmt.gray( + `Loaded ${keysLoaded.size} environment variables from: ${Array.from(envFiles).join(", ") + }`, + ), + ); + } + + // resolves globs to file paths + ///////////// + const globs: string = args._.length ? args._.join(" ") : "**/*.http"; + const filePathsToRun = await globsToFilePaths(globs.split(" ")); + + // runner + ///////////// + let { exitCode, onlyMode } = await runner( + filePathsToRun, + defaultMeta, + args.failFast, + ); + + // warn only mode + ///////////// + if (onlyMode.length) { + if (getDisplayIndex(defaultMeta) > 0) { + console.warn( + fmt.yellow( + `\n${fmt.bgYellow(fmt.bold(" ONLY MODE ")) + } ${onlyMode.length} tests are in "only" mode.`, + ), + ); + if (!exitCode) { + console.error( + fmt.red( + `Failed because the ${fmt.bold('"only"')} option was used at ${onlyMode.join(", ") + }`, + ), + ); + } + } + exitCode ||= 1; + } + + // --watch + ///////////// + if (args.watch) { + const filePathsToJustWatch = await globsToFilePaths( + args.watch.filter((i: boolean | string) => typeof i === "string"), + ); + watchAndRun(filePathsToRun, filePathsToJustWatch, defaultMeta).catch( + console.error, + ); + } else { + exit(exitCode); + } +} + +function logWatchingPaths(filePaths: string[], filePathsToJustWatch: string[]) { + console.info(fmt.dim("\nWatching and running tests from:")); + filePaths.map((_filePath) => relative(Deno.cwd(), _filePath)).forEach(( + _filePath, + ) => console.info(fmt.cyan(` ${_filePath}`))); + if (filePathsToJustWatch.length) { + console.info(fmt.dim("\nRerun when changes from:")); + filePathsToJustWatch.map((_filePath) => relative(Deno.cwd(), _filePath)) + .forEach((_filePath) => console.info(fmt.cyan(` ${_filePath}`))); + } +} + +async function watchAndRun( + filePaths: string[], + filePathsToJustWatch: string[], + defaultMeta: Meta, +) { + const allFilePaths = filePaths.concat(filePathsToJustWatch); + const watcher = Deno.watchFs(allFilePaths); + logWatchingPaths(filePaths, filePathsToJustWatch); + + for await (const event of watcher) { + if (event.kind === "access") { + console.clear(); + await runner(filePaths, defaultMeta); + logWatchingPaths(filePaths, filePathsToJustWatch); + // TODO add force ref or import file + // if (event.paths.some((path) => filePathsToJustWatch.includes(path))) { + // // run all + // console.clear(); + // await runner(filePaths, defaultMeta); + // logWatchingPaths(filePaths, filePathsToJustWatch); + // } else { + // // run just this file + // console.clear(); + // await runner(event.paths, defaultMeta); + // logWatchingPaths(filePaths, filePathsToJustWatch); + // } + } + } +} diff --git a/src/fetchBlock.ts b/src/fetchBlock.ts new file mode 100644 index 0000000..cc9984a --- /dev/null +++ b/src/fetchBlock.ts @@ -0,0 +1,113 @@ +import { + mimesToArrayBuffer, + mimesToBlob, + mimesToFormData, + mimesToJSON, + mimesToText, +} from "./mimes.ts"; + +import { delay } from "https://deno.land/std@0.161.0/async/delay.ts"; + +import { type _Request, _Response, type Block } from "./types.ts"; + +export async function fetchBlock( + block: Block, +): Promise { + const { request } = block; + if (!request) { + throw new Error("block.request is undefined"); + } + const ctl = new AbortController(); + const signal = ctl.signal; + let timeoutId; + const _delay = Number(block.meta.delay); + if (delay) { + await delay(_delay); + } + const timeout = Number(block.meta.timeout); + if (timeout) { + timeoutId = setTimeout(() => ctl.abort(), timeout); + } + + try { + const response = await fetch(request, { signal }); + const actualResponse = _Response.fromResponse(response, request.bodyRaw); + block.actualResponse = actualResponse; + } catch (error) { + if (error.name === "AbortError") { + throw new error.constructor( + `Timeout of ${block.meta.timeout}ms exceeded`, + ); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } + return block; +} + +export async function extractBody( + re: _Response | _Request, +): Promise<_Response | _Request> { + const contentType = re.headers.get("content-type") || ""; + const includes = (ct: string) => contentType.includes(ct); + + if (re.bodyUsed) { + if (re.bodyExtracted !== undefined) { + return re; + } + if (typeof re.bodyRaw === "string") { + const requestExtracted = mimesToJSON.some((ct) => + contentType.includes(ct) + ) + ? JSON.parse(re.bodyRaw as string) + : re.bodyRaw; + re.bodyExtracted = requestExtracted; + return re; + } + return re; + } + + if (!contentType) { + re.bodyExtracted = undefined; + return re; + } + if (mimesToArrayBuffer.some(includes)) { + const body = await re.arrayBuffer(); + re.bodyExtracted = body; + return re; + } + if (mimesToText.some(includes)) { + const body = await re.text(); + re.bodyExtracted = body; + return re; + } + if (mimesToJSON.some(includes)) { + const body = await re.json(); + re.bodyExtracted = body; + return re; + } + if (mimesToBlob.some(includes)) { + const body = await re.blob(); + re.bodyExtracted = body; + return re; + } + if (mimesToFormData.some(includes)) { + const body = await re.formData(); + re.bodyExtracted = body; + return re; + } + throw new Error("Unknown content type " + contentType); +} + +export async function consumeBodies(block: Block): Promise { + const promises = []; + if (!block.expectedResponse?.bodyUsed) { + promises.push(block.expectedResponse?.body?.cancel()); + } + if (!block.actualResponse?.bodyUsed) { + promises.push(block.actualResponse?.body?.cancel()); + } + await Promise.all(promises); +} diff --git a/src/files.ts b/src/files.ts new file mode 100644 index 0000000..0e091c5 --- /dev/null +++ b/src/files.ts @@ -0,0 +1,80 @@ +import { File } from "./types.ts"; +import { Block } from "./types.ts"; + +export function fileTextToBlocks(txt: string, _filePath: string): Block[] { + const blocks: Block[] = []; + const lines = txt.replaceAll("\r", "\n").split("\n"); + let currentBlockText = ""; + let blockStartLine = 0; + let blockEndLine = NaN; + const blockSeparator = /^###/; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + currentBlockText += line + "\n"; + if (blockSeparator.test(line)) { + blockEndLine = i; + const block = new Block({ + text: currentBlockText, + meta: { + _startLine: blockStartLine, + _endLine: blockEndLine, + _filePath, + }, + }); + blocks.push(block); + currentBlockText = ""; + blockStartLine = i + 1; + } + // final block + if (i === lines.length - 1 && currentBlockText) { + blockEndLine = i; + const block = new Block({ + text: currentBlockText, + meta: { + _startLine: blockStartLine, + _endLine: blockEndLine, + _filePath, + }, + }); + blocks.push(block); + } + } + return blocks; +} + +import { expandGlob } from "https://deno.land/std@0.160.0/fs/mod.ts"; + +export async function globsToFilePaths(globs: string[]): Promise { + const filePaths: string[] = []; + + for (const glob of globs) { + for await (const fileFound of expandGlob(glob)) { + if (fileFound.isFile) { + filePaths.push(fileFound.path); + } + } + } + + return filePaths; +} + +export async function filePathsToFiles(filePaths: string[]): Promise { + const files: File[] = []; + + for (const _filePath of filePaths) { + let fileContent = ""; + try { + fileContent = await Deno.readTextFile(_filePath); + } catch { + // console.error(error.message); + throw new Error("File not found: " + _filePath); + + } + const blocks = fileTextToBlocks(fileContent, _filePath); + files.push({ path: _filePath, blocks }); + } + + + return files; +} diff --git a/src/help.ts b/src/help.ts new file mode 100644 index 0000000..0d70e5b --- /dev/null +++ b/src/help.ts @@ -0,0 +1,288 @@ +import * as fmt from "https://deno.land/std@0.160.0/fmt/colors.ts"; + +export const installCommand = + "deno install --reload --unstable --allow-read --allow-env --allow-net --allow-run -f -n tepi https://deno.land/x/tepi/src/cli.ts"; + +export const runRemoteCommand = + "deno run --unstable --allow-read --allow-env --allow-net --allow-run https://deno.land/x/tepi/src/cli.ts"; + +export function help(): void { + const isReadme = !!Deno.env.get("NO_COLOR"); + + // const b = fmt.cyan; + const w = fmt.brightWhite; + // const d = (t:string) => isReadme ? '> '+ t : fmt.dim('> '+ t); + const d = fmt.dim; + const g = fmt.brightGreen; + const orange = (t: string) => fmt.rgb24(t, 0xFF6600); + const codeDelimiter = isReadme ? "`" : ""; + const c = (t: string) => orange(codeDelimiter + t + codeDelimiter); + const codeBlockDelimiter = isReadme ? "\n```" : ""; + const i = fmt.italic; + const codeBlock = ( + t: string, + lang = "", + ) => (i( + codeBlockDelimiter + (isReadme ? lang : "") + "\n" + t + + codeBlockDelimiter, + )); + // const httpHighlight = (t: string) => highlight(t, { language: "http" }); + const title = ` +${g(`-------------------------`)} +${g(`--------- ${fmt.bold("TEPI")} ----------`)} +${g(`-------------------------`)} +${g(`-- A .http Test Runner --`)} +${g(`-------------------------`)} +`; + const helpText = ` +${(codeBlock(title, ""))} +${fmt.bold("Test your HTTP APIs with standard http syntax")} + +${g("## Features:")} + +- 📝 Write end to end API REST tests in ${c(".http")} files +- 🔎 Validate Response status, headers and/or body. +- 🔥 Interpolate javascript with eta template ${c("<%= %>")} +- 🖊 Write metadata as frontmatter yaml +- 📦 Reference by name another test to run them in advance +- ⏱ Set a timeout for each test or globally in milliseconds. After the timeout, the test will fail. +- 🚨 Stop running tests after the first failure. +- 🔋 Use ${("env files")} to load environment variables +- 😎 Fully featured and colorful display modes. (none, minimal, default and full) +- 👁 Watch files for changes and rerun tests. +- 🍯 Standard Response and Request with a automatic getBody() + +${g("## Install:")} + +${codeBlock( + installCommand, + "bash", + ) + } + +Or run remotely width: +${codeBlock( + runRemoteCommand, + "bash", + ) + } + +${g("## Usage:")} + +${w(`tepi [OPTIONS] [FILES|GLOBS...]`)} + +${g("## Options:")} + +${d("* ")}-w ${c("--watch")} ${d("Watch files for changes and rerun tests.") + } +${d("* ")}-t ${c("--timeout")} ${d("Set the timeout for each test in milliseconds. After the timeout, the test will fail.") + } +${d("* ")}-f ${c("--fail-fast")} ${d("Stop running tests after the first failure.") + } +${d("* ")}-d ${c("--display")} ${d("Set the display mode. (none, minimal, default and full)") + } +${d(" * ")} none: ${d(`display nothing`)} +${d(" * ")} minimal: ${d(`display only a minimal summary`)} +${d(" * ")} default: ${d(`list results and full error summary`)} +${d(" * ")} full: ${d(`display also all HTTP requests and responses`)} +${d(" * ")} verbose: ${d(`display also all metadata and not truncate data`) + } +${d("* ")}-h ${c("--help")} ${d("output usage information")} +${d("* ")}-e ${c("--env-file")} ${d("load environment variables from a .env file") + } +${d("* ")} ${c("--no-color")} ${d("output without color")} +${d("* ")} ${c("--upgrade")} ${d("upgrade to the latest version")} + +${g("## Examples:")} + +${c(`tepi`)} +${d(`> Run all .http in the current directory and folders. (same as tepi ./**/*.http)`)} + +${c(`tepi test.http ./test2.http`)} +${d(`> Run test.http and test2.http`)} + +${c(`tepi **/*.http`)} +${d(`> Run all .http in the current directory and folders.`)} + +${c(`tepi rest.http --watch`)} +${d(`> Run rest.http and rerun when it changes`)} + +${c(`tepi rest.http --watch "src/**/*.ts"`)} +${d(`> Run rest.http and rerun when any .ts file in the src folder changes.`)} + +${c(`tepi rest.http --watch "src/**/*.json" --watch "src/**/*.ts"`)} +${d(`> You can use multiple --watch flags.`)} +${d(`> Note: You can use globs here too, but use quotes to avoid the shell expanding them.`)} + +${c(`tepi --timeout 10000`)} +${d(`> Set the timeout for each test in milliseconds. After the timeout, the test will fail.`)} + +${c(`tepi --fail-fast`)} +${d(`> Stop running tests after the first failure.`)} + +${c(`tepi --display minimal`)} +${d(`> Set the display mode. (none, minimal, default and full)`)} + +${c(`tepi --env-file .env --env-file .env.test`)} +${d(`> Load environment variables from a .env and .env.test`)} + + +${g("## HTTP syntax:")} + +* You can use the standard HTTP syntax in your .http files to run a request and response validation. +* Use the ${c("###")} to separate the requests. +* Use frontmatter yaml to set metadata. + +For example, validate the headers, status code, status text and body: +${codeBlock(` +GET https://faker.deno.dev/?body=hola&status=400 + +HTTP/1.1 400 Bad Request +content-type: text/plain; charset=utf-8 + +hola + +### +`) + } + +${g("## Interpolation:")} + +It's deno 🔥 + +Uses eta as template engine, see docs: +${fmt.underline(`https://deno.land/x/eta`)} + +Use ${c("<%= %>")} to interpolate values. + +All the std assertion module is available: +${fmt.underline(`https://deno.land/std/testing/asserts.ts`)} + + +Use ${c("<% %>")} to run custom assertions or custom JS. +For example: +${codeBlock(`GET http://localhost:3000/users + +<% assert(response.status === 200) %> +`) + } +Or: +${codeBlock( + ` <% if (Math.random() > 0.5) { %> + GET http://localhost:3000/users/1 + <% } else { %> + GET http://localhost:3000/users/2 + <% } %> +`, + ) + } + + +${g("### Interpolation scope:")} + +In the Interpolation ${c("<%= %>")} or ${c("<% %>") + } you have access to any Deno API and the following variables: +> request: ${w(`The Request`)} from the actual block. +> meta: ${w(`The metadata`) + } from the actual block. and the frontmatter global metadata. +> response: ${w(`The standard Response object from the fetch API`) + } from the actual request. (only available in the expected response, after the request) +> body: ${w(`The extracted body`)} an alias of ${c("await response.getBody()") + } (only available in the expected response, after the request) + +> [name]: ${w(`the named block already run`)} for example: ${c(`<%= loginTest.body.jwt %>`) + } or ${c(`<%= loginTest.response.status %>`)} + +The Block signature is: +${codeBlock( + `type Block = { + meta: { + [key: string]: any, + }, + request?: Request, + response?: Response, + expectedResponse?: Response, + error?: Error, + body?: any, +}`, + "ts", + ) + } + +For example: +${codeBlock( + `--- +name: login +--- +POST https://example.com/login +Content-Type: application/json + +{"user": "Garn", "password": "1234"} + +HTTP/1.1 200 OK + +### +--- +needs: login +# not really needed, because the requests run in order of declaration +--- +GET https://example.com/onlyAdmin +Authorization: Bearer <%= loginTest.body.jwt %> +Content-Type: application/json`, + "", + )} + +${g("## Special metadata keys:")} + +Explanation of meta.needs, meta.id, meta.description, meta.display, meta.timeout and meta.import + +${g("### meta.needs")} + +The meta.needs is a special metadata value that allows you to run a test in advance and use the result in the current test if needed. + +For example: +${codeBlock( + `--- +needs: login +# will run the login test before this one +--- +GET https://example.com/onlyAdmin +Authorization: Bearer <%= loginTest.body.jwt %> +Content-Type: application/json + +### +--- +name: login +--- +POST https://example.com/login +Content-Type: application/json + +{"user": "Garn", "password": "1234"} + +HTTP/1.1 200 OK +`, + "", + ) + } + +${g("### meta.id and meta.description")} + +The meta.id allows you to identify a test for reference. +The meta.description it's used to display the test name in the console if not set, it will use the meta.id. + +${g("### meta.display:")} + +The meta.display allows you to override the global display mode for a specific test. + +For example: +${codeBlock( + `--- +display: verbose +--- +GET https://example.com/get +`)} +`; + + console.info(helpText); + return; +} diff --git a/src/highlight.ts b/src/highlight.ts new file mode 100644 index 0000000..21417cb --- /dev/null +++ b/src/highlight.ts @@ -0,0 +1,34 @@ +import { extension } from "https://deno.land/std@0.160.0/media_types/mod.ts?source=cli"; +// @ts-ignore ¿?¿ it has a named highlight export +// import { highlight as hl,supportsLanguage } from "npm:cli-highlight"; + +let supportsLang = (_: string) => true; +let hl = (code: string, { language: _ }: { language: string }) => code; + +try { + const { highlight, supportsLanguage } = await import("npm:cli-highlight"); + hl = highlight; + supportsLang = supportsLanguage; +} catch { + console.error("cli-highlight not found"); +} + +export function highlight(txt: string, language: string): string { + if (language === "json") { + return Deno.inspect(JSON.parse(txt), { colors: !Deno.noColor }); + } + if (supportsLang(language)) return hl(txt, { language }); + return txt; +} + +export function contentTypeToLanguage(contentType: string): string { + let language = extension(contentType); + if (!language) { + const [mime] = contentType.split(";"); + [, language] = mime.split("/"); + language = language.replace(/\+.*/, ""); + } + language ||= "text"; + language = language !== "plain" ? language : "text"; + return language; +} diff --git a/src/mimes.ts b/src/mimes.ts new file mode 100644 index 0000000..01c06fb --- /dev/null +++ b/src/mimes.ts @@ -0,0 +1,60 @@ +export const mimesToArrayBuffer = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/tiff", +]; + +export const mimesToText = [ + "text/plain", + "text/html", + "text/css", + "text/javascript", + "text/xml", + "application/xml", + "text/markdown", + "text/csv", + "text/yaml", + "image/svg+xml", + "image/svg", + "application/x-yaml", + "application/yaml", +]; + +export const mimesToJSON = [ + "application/json", + "application/ld+json", + "application/manifest+json", + "application/schema+json", + "application/vnd.api+json", + "application/vnd.geo+json", +]; + +export const mimesToBlob = [ + "application/octet-stream", + "application/pdf", + "application/zip", + "application/x-rar-compressed", + "application/x-7z-compressed", + "application/x-tar", + "application/x-gzip", + "application/x-bzip2", + "application/x-xz", + "application/x-lzma", + "application/x-lzip", + "application/x-lzop", + "application/x-snappy-framed", + "application/x-msdownload", + "application/x-msi", + "application/x-ms-shortcut", + "application/x-ms-wim", + "application/x-ms-xbap", + "application/x-msaccess", +]; + +export const mimesToFormData = [ + "multipart/form-data", + "application/x-www-form-urlencoded", +]; diff --git a/src/parseBlockText.ts b/src/parseBlockText.ts new file mode 100644 index 0000000..3434047 --- /dev/null +++ b/src/parseBlockText.ts @@ -0,0 +1,228 @@ +import type { Block, Meta } from "./types.ts"; +import { _Request, _Response, httpMethods } from "./types.ts"; +import * as eta from "https://deno.land/x/eta@v1.12.3/mod.ts"; +import { extract } from "https://deno.land/std@0.160.0/encoding/front_matter.ts"; + +async function renderTemplate(template: string, data: Record) { + const result = await eta.render( + template, + data, + { + async: true, + useWith: true, + rmWhitespace: false, + autoTrim: false, + // TODO: add custom tags?? + // tags: ["{{", "}}"] + }, + ); + return result; +} + +export const isRequestStartLine = (line: string): boolean => + httpMethods.some((method) => line.trim().startsWith(method)); + +const isResponseStartLine = (line: string): boolean => + line.trim().startsWith("HTTP/"); + +const isHeaderLine = (line: string): boolean => + !!line.trim().match(/^[^:]+:\s*.+$/); + +export async function parseBlockText(block: Block): Promise { + block.meta = await parseMetaFromText(block.text); + block.request = await parseRequestFromText(block); + block.expectedResponse = await parseResponseFromText(block.text); + return block; +} + +const findFrontMatterTextRegex = /^---\s*([\s\S]*?)\s*---\s*/gm; +export async function parseMetaFromText( + textRaw = "", + dataToInterpolate = {}, +): Promise { + const meta: Meta = {}; + const lines: string[] = splitLines(textRaw); + const requestStartLine = lines.findIndex(isRequestStartLine); + const metaText = lines.slice(0, requestStartLine).join("\n"); + const text = await renderTemplate(metaText, dataToInterpolate) || ""; + + const frontMatterText = text.match(findFrontMatterTextRegex)?.[0] || ""; + + if (frontMatterText) { + const data = extract(frontMatterText); + Object.assign(meta, data.attrs); + } + return meta; +} + +function splitLines(text: string): string[] { + return text.replaceAll("\r", "\n").split("\n"); +} + +export async function parseRequestFromText( + block: Block, + dataToInterpolate = {}, +): Promise<_Request | undefined> { + const textRaw = block.text || ""; + const meta = block.meta; + const linesRaw: string[] = splitLines(textRaw); + const requestStartLine = linesRaw.findIndex(isRequestStartLine); + if (requestStartLine === -1) { + return; + } + let requestEndLine = linesRaw.findIndex( + isResponseStartLine, + requestStartLine, + ); + if (requestEndLine === -1) requestEndLine = linesRaw.length; + + const requestText = linesRaw.slice(requestStartLine, requestEndLine).join( + "\n", + ); + + const text = await renderTemplate(requestText, dataToInterpolate) || ""; + const lines = splitLines(text); + + let url = ""; + const headers: Headers = new Headers(); + const requestInit: RequestInit = { + method: "GET", + mode: "cors", + credentials: "same-origin", + cache: "default", + redirect: "follow", + referrer: "client", + referrerPolicy: "no-referrer-when-downgrade", + integrity: "", + keepalive: false, + signal: undefined, + }; + + for (const key in meta) { + if (key in requestInit) { + requestInit[key as keyof RequestInit] = meta[key]; + } + } + let lookingFor = "url"; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("###")) { + break; + } + if (lookingFor !== "body" && trimmed.startsWith("#")) { + continue; + } + + if (lookingFor === "url" && isRequestStartLine(line)) { + const [method, _url] = trimmed.split(" "); + + requestInit.method = method; + url = _url; + + lookingFor = "headers"; + continue; + } + if (lookingFor === "headers" && isHeaderLine(line)) { + const [key, value] = extractHeader(trimmed); + headers.set(key, value); + continue; + } + if (lookingFor === "headers" && !isHeaderLine(line)) { + lookingFor = "body"; + } + if (lookingFor === "body") { + requestInit.body = (requestInit.body || "") + "\n" + line; + } + } + requestInit.body = (requestInit.body as string || "").trim(); + if (requestInit.body === "") { + requestInit.body = null; + } + requestInit.headers = headers; + + let host = requestInit.headers.get("host") || meta.host || ""; + host = String(host).trim(); + const hasProtocol = url.match(/^https?:\/\//); + if (host && !hasProtocol) { + if (host.endsWith("/") && url.startsWith("/")) { + host = host.slice(0, -1); + } + url = host + (url || ""); + } + + if (!url.match(/^https?:\/\//)) { + url = `http://${url}`; + } + + const request: _Request = new _Request(url, requestInit); + + return request; +} + +export async function parseResponseFromText( + textRaw = "", + dataToInterpolate = {}, +): Promise<_Response | undefined> { + const linesRaw: string[] = splitLines(textRaw); + const responseInit: ResponseInit = {}; + const headers = new Headers(); + let responseBody: BodyInit | null = ""; + + const statusLine = linesRaw.findIndex(isResponseStartLine); + if (statusLine === -1) return; + + const responseText = linesRaw.slice(statusLine).join("\n"); + const text = await renderTemplate(responseText, dataToInterpolate) || ""; + const lines = splitLines(text); + + let lookingFor = "status"; + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith("###")) { + break; + } + if (lookingFor !== "body" && trimmed.startsWith("#")) { + continue; + } + + if (lookingFor !== "body" && trimmed.startsWith("#")) { + continue; + } + if (lookingFor === "status" && isResponseStartLine(line)) { + const [, status, ...statusText] = trimmed.split(" "); + responseInit.status = parseInt(status); + responseInit.statusText = statusText.join(" "); + lookingFor = "headers"; + continue; + } + if (lookingFor === "headers" && isHeaderLine(line)) { + const [key, value] = extractHeader(trimmed); + headers.set(key, value); + continue; + } + if (lookingFor !== "body" && !trimmed) { + if (lookingFor === "headers") { + lookingFor = "body"; + } + continue; + } + if (lookingFor === "body") { + responseBody += "\n" + line; + continue; + } + } + + responseInit.headers = headers; + responseBody = responseBody.trim(); + responseBody ||= null; + const response: _Response = new _Response(responseBody, responseInit); + return response; +} + +function extractHeader(line: string): [string, string] { + const [key, ...values] = line.split(":"); + const value = values.join(":").trim(); + return [key, value]; +} diff --git a/src/print.ts b/src/print.ts new file mode 100644 index 0000000..9f6ce0f --- /dev/null +++ b/src/print.ts @@ -0,0 +1,306 @@ +import * as fmt from "https://deno.land/std@0.160.0/fmt/colors.ts"; +import { getImageStrings } from "https://deno.land/x/terminal_images@3.0.0/mod.ts"; +import { mimesToArrayBuffer, mimesToBlob, mimesToText } from "./mimes.ts"; +import { _Request, _Response, Block, Meta } from "./types.ts"; +import { contentTypeToLanguage, highlight } from "./highlight.ts"; + +type FmtMethod = keyof typeof fmt; + +// TODO make it work on CI +function consoleSize(): { rows: number; columns: number } { + try { + const { columns, rows } = Deno.consoleSize(); + return { columns, rows }; + } catch { + return { columns: 150, rows: 150 }; + } +} + +function printTitle(title: string, fmtMethod: FmtMethod = "gray") { + const consoleWidth = consoleSize().columns; + // @ts-ignore // TODO: fix this + const titleStr = fmt[fmtMethod](` ${title} `, undefined) as string; + let padLength = 2 + Math.floor((consoleWidth - titleStr.length) / 2); + padLength = padLength < 0 ? 0 : padLength; + const separator = fmt.gray("-"); + const output = `${separator.repeat(5)} ${titleStr} ${ + separator.repeat(padLength) + }`; + console.info(output); +} + +export const DISPLAYS = [ + "none", + "minimal", + "default", + "full", + "verbose", +]; +export function getDisplayIndex(meta: Meta): number { + const display = meta.display; + const index = DISPLAYS.indexOf(display); + if (index === -1) { + return Infinity; + } + return index; +} + +export async function printBlock(block: Block): Promise { + const { request, actualResponse, expectedResponse, error, meta } = block; + + if (block.meta.ignore) { + return; + } + const displayIndex = getDisplayIndex(meta); + if (displayIndex < 3) { + return; + } + console.group(); + if ( + block.meta._relativeFilePath && + (request || actualResponse || expectedResponse || error) + ) { + const path = `\n${fmt.dim("Data from:")} ${ + fmt.cyan(`${block.meta._relativeFilePath}:${block.meta._startLine}`) + }`; + console.info(path); + } + if (meta && displayIndex >= 4) { + printTitle("⬇ Meta ⬇"); + console.info(metaToText(meta)); + } + if (request) { + console.info(""); + printTitle("⬇ Request ⬇"); + + console.info(requestToText(request)); + console.info(headersToText(request.headers, displayIndex)); + await printBody(request, displayIndex); + } + + if (actualResponse) { + printTitle("⬇ Response ⬇"); + + console.info(responseToText(actualResponse)); + console.info(headersToText(actualResponse.headers, displayIndex)); + await printBody(actualResponse, displayIndex); + } + if (expectedResponse) { + printTitle("⬇ Expected Response ⬇"); + console.info(responseToText(expectedResponse)); + console.info(headersToText(expectedResponse.headers, displayIndex)); + await printBody(expectedResponse, displayIndex); + } + if (error) { + printError(block); + } + console.groupEnd(); +} +function metaToText(meta: Meta): string { + let output = ""; + let maxLengthKey = 0; + for (const key in meta) { + if (key.startsWith("_") || meta[key] === undefined) continue; + + if (key.length > maxLengthKey) { + maxLengthKey = key.length; + } + } + for (const key in meta) { + if (key.startsWith("_") || meta[key] === undefined) continue; + const _key = key + ":"; + + output += fmt.dim(`${_key.padEnd(maxLengthKey + 3)} ${meta[key]}\n`); + } + return output; +} + +export function printErrorsSummary(blocks: Block[]): void { + const blocksWidthErrors = blocks.filter((b) => b.error); + + if (blocksWidthErrors.length) { + console.info(); + printTitle("⬇ Failures Summary ⬇", "brightRed"); + console.info(); + } + let firstError = true; + for (const { error, meta } of blocksWidthErrors) { + if (!error) { + continue; + } + const maximumLength = consoleSize().columns / 2; + const path = `${meta._relativeFilePath}:${1 + (meta._startLine || 0)}`; + const messagePath = `${fmt.dim("at:")} ${fmt.cyan(path)}`; + // const messageText = `${fmt.red("✖")} ${fmt.white(error.message)}`; + let message = ""; + + if (!meta._errorDisplayed) { + firstError || console.error(fmt.dim("------------------")); + if (getDisplayIndex(meta) === 1) { + // minimal + const messageText = fmt.stripColor(`${error.name}: ${error.message}`); + const trimmedMessage = messageText.trim().replaceAll(/\s+/g, " "); + const messageLength = trimmedMessage.length; + const needsToTruncate = messageLength > maximumLength; + const truncatedMessage = needsToTruncate + ? `${trimmedMessage.slice(0, maximumLength - 3)}...` + : trimmedMessage; + const messagePadded = truncatedMessage.padEnd(maximumLength); + message = `${fmt.red("✖")} ${fmt.white(messagePadded)} ${messagePath}`; + } else { + // default + message = `${fmt.red("✖")} ${fmt.bold(error.name)}: ${ + fmt.white(error.message) + } \n${messagePath}`; + } + } else { + message = `${fmt.bold(error.name).padEnd(maximumLength)} ${messagePath}`; + } + console.error(message); + firstError = false; + } +} + +export function printError(block: Block): void { + const error = block.error; + if (!error) return; + + const path = block.meta._relativeFilePath; + + printTitle("⬇ Error ⬇"); + + block.description && console.error(fmt.brightRed(block.description)); + console.error( + fmt.dim("At:\n"), + fmt.cyan(`${path}:${1 + (block.meta._startLine || 0)}`), + ); + console.error( + fmt.dim("Message:\n"), + fmt.bold(error?.name) + ":", + fmt.white(error?.message), + ); + // error?.stack && console.error(fmt.dim('Trace:\n'), fmt.dim(error?.stack)); + error?.cause && + console.error(fmt.dim("Cause:\n"), fmt.dim(String(error?.cause))); + block.meta._errorDisplayed = true; + console.error(); +} + +export function requestToText(request: Request): string { + const method = request.method; + const url = request.url; + return `${fmt.brightWhite(`${fmt.yellow(method)} ${url}`)}`; +} +export function responseToText(response: Response): string { + const statusColor = response.status >= 200 && response.status < 300 + ? fmt.green + : response.status >= 300 && response.status < 400 + ? fmt.yellow + : response.status >= 400 && response.status < 500 + ? fmt.red + : fmt.bgRed; + + const status = statusColor(String(response.status)); + const statusText = response.statusText; + + return `${fmt.dim(`HTTP/1.1`)} ${fmt.bold(`${status} ${statusText}`)}`; +} + +function truncateCols(str: string, maxLength: number): string { + const length = str.length; + if (length > maxLength) { + return `${str.slice(0, maxLength - 3)}...`; + } + return str; +} + +function truncateRows(str: string, maxLength: number): string { + const lines = str.split("\n"); + + if (lines.length > maxLength) { + return lines.slice(0, maxLength - 3).join("\n") + fmt.bold("\n.\n.\n."); + } + return str; +} + +export function headersToText(headers: Headers, displayIndex: number): string { + const halfWidth = -5 + consoleSize().columns / 2; + let maxLengthKey = 0; + const truncateAt = halfWidth; + + let result = ""; + let truncate = truncateCols; + + if (displayIndex >= 4) { + // verbose, do not truncate + truncate = (str: string, _: number) => str; + } + + for (const [key] of headers.entries()) { + maxLengthKey = Math.max(maxLengthKey, truncate(key, truncateAt).length); + } + for (const [key, value] of headers.entries()) { + result += `${ + fmt.gray(`${truncate(key, truncateAt)}:`.padEnd(maxLengthKey + 1)) + } ${fmt.dim(truncate(value, truncateAt))}\n`; + } + + return result; +} +export async function printBody( + re: _Response | _Request, + displayIndex = 4, +): Promise { + let truncate = truncateRows; + const MAX_BODY_LINES = 40; + try { + let body = await bodyToText(re); + body &&= body.trim() + "\n"; + if (displayIndex >= 4) { + truncate = (str: string, _: number) => str; + } + console.info(truncate(body, MAX_BODY_LINES)); + } catch (error) { + console.error(fmt.bgYellow(" Error printing block ")); + console.error(fmt.red(error.name), error.message); + // console.error(error.stack); + } +} + +async function bodyToText(re: _Request | _Response): Promise { + const body = await re.getBody(); + + const contentType = re.headers.get("content-type") || ""; + if (!contentType || !body) { + return ""; + } + + const includes = (ct: string) => contentType.includes(ct); + + if (mimesToArrayBuffer.some(includes)) { + return await imageToText(body as ArrayBuffer); + } + if (mimesToBlob.some(includes)) { + return `${Deno.inspect(body)}`; + } + + const bodyStr = typeof body === "string" + ? body + : JSON.stringify(body, null, 2); + const language = contentTypeToLanguage(contentType); + + if (language) { + return highlight(bodyStr, language); + } + if (mimesToText.some(includes)) { + return bodyStr; + } + + throw new Error("Unknown content type " + contentType); +} + +async function imageToText(body: ArrayBuffer): Promise { + const rawFile = new Uint8Array(body); + const options = { rawFile }; + return [...await getImageStrings(options)].join(""); +} diff --git a/src/runner.ts b/src/runner.ts new file mode 100644 index 0000000..b421cb1 --- /dev/null +++ b/src/runner.ts @@ -0,0 +1,342 @@ +import { filePathsToFiles } from "./files.ts"; +import { Block, File, GlobalData, Meta } from "./types.ts"; +import { consumeBodies, fetchBlock } from "./fetchBlock.ts"; +import { assertResponse } from "./assertResponse.ts"; +import * as fmt from "https://deno.land/std@0.160.0/fmt/colors.ts"; +import { wait } from "https://deno.land/x/wait@0.1.12/mod.ts"; +import { relative, isAbsolute, resolve, dirname } from "https://deno.land/std@0.160.0/path/posix.ts"; +import { getDisplayIndex, printBlock, printErrorsSummary } from "./print.ts"; +import { ms } from "https://deno.land/x/ms@v0.1.0/ms.ts"; +// import ms from "npm:ms"; +import { + parseMetaFromText, + parseRequestFromText, + parseResponseFromText, +} from "./parseBlockText.ts"; +import * as assertions from "https://deno.land/std@0.160.0/testing/asserts.ts"; +const noop = (..._: unknown[]): void => { }; + +export async function runner( + filePaths: string[], + defaultMeta: Meta, + failFast = false, +): Promise<{ files: File[]; exitCode: number; onlyMode: Set }> { + + + let successfulBlocks = 0; + let failedBlocks = 0; + let ignoredBlocks = 0; + const blocksDone: Block[] = []; + const startGlobalTime = Date.now(); + + const onlyMode = new Set(); + const mustBeImported = new Set(); + + + const files: File[] = await filePathsToFiles(filePaths); + + + + const globalData: GlobalData = { + meta: { + ...defaultMeta, + get ignore() { + return undefined; + // do not save ignore in global meta + }, + get only() { + // do not save only in global meta + return undefined; + }, + }, + _files: files, + _blocksDone: {}, + _blocksAlreadyReferenced: {}, + }; + + + // parse all metadata first + try { + await processMetadata(files, globalData, onlyMode, mustBeImported, blocksDone); + } catch (error) { + console.error(`Error while parsing metadata`); + console.error(error.message); + return { files, exitCode: 1, onlyMode }; + } + + + if (onlyMode.size) { + for (const file of files) { + for (const block of file.blocks) { + if (!block.meta.only) { + block.meta.ignore = true; + } + } + } + } + + for (const file of files) { + const relativePath = file.relativePath; + const path = fmt.dim(`running ${relativePath}`); + let pathSpinner; + + if (getDisplayIndex(defaultMeta) === 0) { + // display none + } else if (getDisplayIndex(defaultMeta) === 1) { + pathSpinner = wait({ text: path }); + pathSpinner.start(); + } else { + console.info(path); + } + + let _isFirstBlock = true; + for (const block of file.blocks) { + block.meta._relativeFilePath = relativePath; + block.meta._isFirstBlock = _isFirstBlock; + if (_isFirstBlock) { + _isFirstBlock = false; + } + const [...blocks] = await runBlock(block, globalData); + blocksDone.push(...blocks); + + if (block.meta._isIgnoredBlock) { + ignoredBlocks++; + } + if (block.meta._isFailedBlock) { + failedBlocks++; + } + if (block.meta._isSuccessfulBlock) { + successfulBlocks++; + } + + if (failFast && failedBlocks) { + if (getDisplayIndex(defaultMeta) !== 0) { + printErrorsSummary(blocksDone); + } + const status = block.actualResponse?.status || 1; + console.error(fmt.red(`\nFAIL FAST: exiting with status ${status}`)); + return { + files, + exitCode: status, + onlyMode, + }; + } + } + + pathSpinner?.stop(); + pathSpinner?.clear(); + } + if (getDisplayIndex(defaultMeta) !== 0) { + printErrorsSummary(blocksDone); + + const statusText = failedBlocks + ? fmt.bgRed(" FAIL ") + : fmt.bgBrightGreen(" PASS "); + + const totalBlocks = successfulBlocks + failedBlocks + ignoredBlocks; + const elapsedGlobalTime = Date.now() - startGlobalTime; + const prettyGlobalTime = fmt.dim(`(${ms(elapsedGlobalTime)})`); + console.info(); + console.info( + fmt.bold(`${statusText}`), + `${fmt.white(String(totalBlocks))} tests, ${fmt.green(String(successfulBlocks)) + } passed, ${fmt.red(String(failedBlocks))} failed, ${fmt.yellow(String(ignoredBlocks)) + } ignored ${prettyGlobalTime}`, + ); + } + globalData._blocksDone = {}; // clean up blocks referenced + return { files, exitCode: failedBlocks, onlyMode }; +} + + +async function processMetadata(files: File[], globalData: GlobalData, onlyMode: Set, mustBeImported: Set, blocksDone: Block[]) { + for (const file of files) { + file.relativePath = relative(Deno.cwd(), file.path); + + for (const block of file.blocks) { + try { + const meta = await parseMetaFromText(block.text, { + ...globalData, + ...block, + ...assertions, + }); + block.meta._relativeFilePath ??= file.relativePath; + + if (meta.only) { + onlyMode.add( + `${block.meta._relativeFilePath}:${block.meta._startLine}` + ); + } + if (meta.import) { + if (isAbsolute(meta.import)) { + mustBeImported.add(meta.import); + } else { + mustBeImported.add(resolve(dirname(file.path), meta.import)); + } + } + block.meta = { + ...globalData.meta, + ...block.meta, + ...meta, + }; + } catch (error) { + block.error = error; + block.meta._isDoneBlock = true; + blocksDone.push(block); + } + } + } + // meta.import logic + if (mustBeImported.size > 0) { + const allAbsolutePaths = files.map((f) => f.path); + const needsImport = Array.from(mustBeImported).filter( + (path) => !allAbsolutePaths.includes(path), + ); + const newFiles = await filePathsToFiles(needsImport); + const _mustBeImported = new Set(); + await processMetadata(newFiles, globalData, onlyMode, _mustBeImported, blocksDone); + files.unshift(...newFiles); + files.sort(file => mustBeImported.has(file.path) ? -1 : 1) + } +} + +async function runBlock( + block: Block, + globalData: GlobalData, +): Promise { + const startTime = Date.now(); + let spinner; + const blocksDone = [block]; + if (block.meta._isDoneBlock) { + return []; + } + try { + if (getDisplayIndex(block.meta) >= 2) { + spinner = wait({ + prefix: fmt.dim("-"), + text: fmt.dim(block.description), + color: "cyan", + spinner: "dots4", + interval: 200, + discardStdin: true, + }); + } else { + spinner = { + start: noop, + stopAndPersist: noop, + update: noop, + text: "", + }; + } + if (block.meta.needs) { + const blockReferenced = globalData._files.flatMap((file) => file.blocks) + .find((b) => b.meta.name === block.meta.needs); + if (!blockReferenced) { + spinner?.start(); + throw new Error(`Block referenced not found: ${block.meta.needs}`); + } else { + // Evict infinity loop + if ( + globalData + ._blocksAlreadyReferenced[blockReferenced.meta.name as string] + ) { + return []; + // throw new Error(`Block referenced already referenced: ${block.meta.needs}`); + } + globalData._blocksAlreadyReferenced[block.meta.needs as string] = + blockReferenced; + const [...blocks] = await runBlock(blockReferenced, globalData); + blocksDone.push(...blocks); + } + } + + block.meta = { + ...globalData.meta, + ...block.meta, + }; + + block.request = await parseRequestFromText(block, { + ...globalData._blocksDone, + ...block, + ...assertions, + }); + spinner.text = fmt.white(block.description); + if (block.meta._isFirstBlock && !block.request) { + globalData.meta = { ...globalData.meta, ...block.meta }; + } + + if (!block.request) { + block.meta._isEmptyBlock = true; + return blocksDone; + } + + spinner?.start(); + + if (block.meta.ignore) { + block.meta._isIgnoredBlock = true; + spinner?.stopAndPersist({ + symbol: fmt.yellow(""), + text: fmt.yellow(block.description), + }); + return blocksDone; + } + + if (block.error) { + throw block.error; + } + + await fetchBlock(block); + block.expectedResponse = await parseResponseFromText( + block.text, + { + ...globalData._blocksDone, + ...block, + ...assertions, + body: await block.actualResponse?.getBody(), + // body: block.body, + response: block.response, + }, + ); + + if (block.expectedResponse) { + await assertResponse(block); + } + + const _elapsedTime = Date.now() - startTime; + block.meta._elapsedTime = _elapsedTime; + const status = String(block.actualResponse?.status); + spinner?.stopAndPersist({ + symbol: fmt.green("✓"), + text: fmt.green(block.description) + ` ${fmt.bold(status)}` + + fmt.dim(` ${ms(_elapsedTime)}`), + }); + + block.meta._isSuccessfulBlock = true; + return blocksDone; + } catch (error) { + block.error = error; + + const _elapsedTime = Date.now() - startTime; + block.meta._elapsedTime = _elapsedTime; + const status = String(block.actualResponse?.status || ""); + const statusText = status ? fmt.bold(" " + status) : fmt.bold(" ERR"); + const prettyTime = fmt.dim(` ${ms(_elapsedTime)}`); + spinner?.stopAndPersist({ + symbol: fmt.brightRed("✖"), + text: fmt.red(block.description) + statusText + prettyTime, + }); + + block.meta._isFailedBlock = true; + return blocksDone; + } finally { + await printBlock(block); + + await consumeBodies(block); + block.meta._isDoneBlock = true; + if (block.meta.name) { + const name = block.meta.name as string; + block.body = await block.actualResponse?.getBody(); + globalData._blocksDone[name] = block; + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..862e1eb --- /dev/null +++ b/src/types.ts @@ -0,0 +1,140 @@ +import { extractBody } from "./fetchBlock.ts"; +// TODO +// import makeSynchronous from 'npm:make-synchronous'; + +export class _Response extends Response { + bodyRaw?: BodyInit | null; + #bodyExtracted?: unknown; + + static fromResponse( + response: Response, + bodyRaw?: BodyInit | null, + ): _Response { + const _response = new _Response(response.body, response); + _response.bodyRaw = bodyRaw; + return _response; + } + constructor(body?: BodyInit | null | undefined, init?: ResponseInit) { + super(body, init); + this.bodyRaw = body; + } + async getBody(): Promise { + await extractBody(this); + return this.#bodyExtracted; + } + get bodyExtracted() { + return this.#bodyExtracted; + } + set bodyExtracted(value) { + this.#bodyExtracted = value; + } +} + +export class _Request extends Request { + bodyRaw?: BodyInit | null; + #bodyExtracted?: unknown; + constructor(input: RequestInfo, init?: RequestInit) { + super(input, init); + this.bodyRaw = init?.body; + } + async getBody(): Promise { + await extractBody(this); + return this.bodyExtracted; + } + get bodyExtracted() { + return this.#bodyExtracted; + } + set bodyExtracted(value) { + this.#bodyExtracted = value; + } +} + +export type Meta = { + _elapsedTime?: number | string; + _startLine?: number; + _endLine?: number; + _filePath?: string; + _relativeFilePath?: string; + + _isDoneBlock?: boolean; + _isSuccessfulBlock?: boolean; + _isFailedBlock?: boolean; + _isIgnoredBlock?: boolean; + _errorDisplayed?: boolean; + + // deno-lint-ignore no-explicit-any + [key: string]: any; +}; + +export type BodyExtracted = { body: unknown; contentType: string }; + +export class Block { + text: string; + meta: Meta; + request?: _Request; + expectedResponse?: _Response; + actualResponse?: _Response; + error?: Error; + body?: unknown; + // #getBodySync: () => unknown; + // #getBodyAsync:() => Promise; + + constructor(obj: Partial = {}) { + this.text = obj.text || ""; + this.meta = obj.meta || {}; + this.expectedResponse = obj.expectedResponse; + this.actualResponse = obj.actualResponse; + // this.#getBodyAsync = this.actualResponse?.getBody || ( function(): Promise { return Promise.resolve()}) + // this.#getBodySync = makeSynchronous(this.#getBodyAsync) + } + // get body(): unknown { + // return this.#getBodySync(); + // } + get description(): string { + if (this.meta.description) { + return this.meta.description; + } + if (this.meta.name) { + return this.meta.name; + } + if (this.request) { + return `${this.request.method} ${this.request.url}`; + } + const lines = this.text.split("\n"); + return lines.find((l) => l.trim()) || "---empty block---"; + } + get response() { + return this.actualResponse; + } +} + +export type File = { + path: string; + relativePath?: string; + blocks: Block[]; +}; + +export type GlobalData = { + meta: Meta; + _files: File[]; + _blocksAlreadyReferenced: { + [key: string]: Block; + }; + _blocksDone: { + [key: string]: Block; + }; +}; + +// FETCH VALID HTTP METHODS + +export const httpMethods = [ + "GET", + "HEAD", + "POST", + "PUT", + "DELETE", + "CONNECT", + "OPTIONS", + "TRACE", + "PATCH", +]; diff --git a/test/assertResponse.test.ts b/test/assertResponse.test.ts new file mode 100644 index 0000000..4b4cd96 --- /dev/null +++ b/test/assertResponse.test.ts @@ -0,0 +1,134 @@ +import { assertRejects } from "https://deno.land/std@0.160.0/testing/asserts.ts"; +import { assertResponse } from "../src/assertResponse.ts"; +import { _Response, Block } from "../src/types.ts"; + +Deno.test("[assertResponse] with expectedResponse throws error checking status", async () => { + const expectedResponse = new _Response(null, { status: 400 }); + const actualResponse = new _Response(null, { status: 403 }); + await assertRejects(async () => { + await assertResponse( + new Block({ + expectedResponse, + actualResponse, + }), + ); + }); +}); + +Deno.test("[assertResponse] with expectedResponse throws error checking statusText", async () => { + const expectedResponse = new _Response(null, { + status: 400, + statusText: "Bad Request", + }); + const actualResponse = new _Response(null, { + status: 400, + statusText: "Forbidden", + }); + await assertRejects(async () => { + await assertResponse( + new Block({ + expectedResponse, + actualResponse, + }), + ); + }); +}); +Deno.test("[assertResponse] with no expectedResponse not throws", async () => { + const expectedResponse = new _Response(null, { + status: 400, + statusText: "Bad Request", + }); + const actualResponse = new _Response(null, { + status: 400, + statusText: "Bad Request", + }); + await assertResponse( + new Block({ + expectedResponse, + actualResponse, + }), + ); +}); + +Deno.test("[assertResponse] with expectedResponse plain test body", async () => { + const expectedResponse = new _Response("foo", { status: 200 }); + const actualResponse = new _Response("foo", { status: 200 }); + await assertResponse( + new Block({ + expectedResponse, + actualResponse, + }), + ); +}); + +Deno.test("[assertResponse] with expectedResponse json body", async () => { + const expectedResponse = new _Response('{"foo": "bar"}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + const actualResponse = new _Response('{ "foo" : "bar" }', { + status: 200, + headers: { "content-type": "application/json" }, + }); + await assertResponse( + new Block({ + expectedResponse, + actualResponse, + }), + ); +}); + +Deno.test("[assertResponse] with expectedResponse json test body with regexp", async () => { + const expectedResponse = new _Response('{"foo": "bar"}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + const actualResponse = new _Response('{"foo": "bar"}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + await assertResponse( + new Block({ + expectedResponse, + actualResponse, + }), + ); +}); + +Deno.test( + "[assertResponse] must throw with different bodies", // { only: true }, + async () => { + const expectedResponse = new _Response('{"foo": "bar"}', { + headers: { "content-type": "application/json" }, + }); + const actualResponse = new _Response('{"foo": "baz"}', { + headers: { "content-type": "application/json" }, + }); + await assertRejects(async () => { + await assertResponse( + new Block({ + expectedResponse, + actualResponse, + }), + ); + }); + }, +); + +Deno.test( + "[assertResponse] must not throw with same body", // { only: true }, + async () => { + const expectedResponse = new _Response('{"foo": "bar"}', { + headers: { "content-type": "application/json" }, + }); + const actualResponse = new _Response('{ "foo" : "bar" } ', { + headers: { "content-type": "application/json" }, + }); + await assertResponse( + new Block({ + expectedResponse, + actualResponse, + }), + ); + }, +); diff --git a/test/e2e.test.ts b/test/e2e.test.ts new file mode 100644 index 0000000..6156d09 --- /dev/null +++ b/test/e2e.test.ts @@ -0,0 +1,97 @@ +import { + assert, + assertEquals, +} from "https://deno.land/std@0.160.0/testing/asserts.ts"; +import { installCommand, runRemoteCommand } from "../src/help.ts"; + +function textDecode(buffer: Uint8Array) { + return new TextDecoder().decode(buffer); +} + +async function run(command: string) { + const [cmd, ...args] = command.split(/\s+/); + const { code, stdout, stderr, success } = await Deno.spawn(cmd, { args }); + const out = textDecode(stdout); + const err = textDecode(stderr); + + return { code, err, out, success }; +} +const tepi = "deno run -A --unstable ./src/cli.ts "; + +Deno.test("[e2e] must return code 0 when all tests pass", async () => { + const { code, err, out, success } = await run(tepi + "http/pass.http"); + assertEquals(err, ""); + assert(out.length > 0); + assertEquals(code, 0); + assertEquals(success, true); +}); + +Deno.test("[e2e] must return the code of failing tests", async () => { + const { code } = await run(tepi + "http/failFast.http"); + assertEquals(code, 2); +}); + +Deno.test("[e2e] must return code 1 when fails on failFast mode", async () => { + const { code } = await run(tepi + "http/failFast.http --failFast"); + assertEquals(code, 1); +}); + +Deno.test( + "[e2e] display none all tests", + { ignore: Math.random() < 0.95 }, + async () => { + const { code, err, out, success } = await run(tepi + "--display none"); + assertEquals(err, ""); + assertEquals(out, ""); + assertEquals(code, 0); + assertEquals(success, true); + }, +); +Deno.test("[e2e] display none", async () => { + const { code, err, out, success } = await run( + tepi + "--display none http/parser.http", + ); + assertEquals(err, ""); + assertEquals(out, ""); + assertEquals(code, 2); + assertEquals(success, false); +}); + + +Deno.test("[e2e] run help", async () => { + const { code, err, out, success } = await run( + tepi + "--help", + ); + assertEquals(err, ""); + assertEquals(out.length > 0, true); + assertEquals(code, 0); + assertEquals(success, true); + }); + +const mustInstall = Math.random() < 0.95; + +Deno.test("[e2e] help commands must work: installCommand", { + ignore: mustInstall, +}, async () => { + const { code, out, success } = await run(installCommand); + assertEquals(code, 0); + assertEquals(success, true); + assert(out.length > 0); +}); + +Deno.test("[e2e] keep install local", { ignore: mustInstall }, async () => { + const { code, out, success } = await run("deno task install"); + assert(out.length > 0); + assertEquals(code, 0); + assertEquals(success, true); +}); + +Deno.test("[e2e] help commands must work: runRemoteCommand", { + // only: true, + ignore: Math.random() < 0.95, +}, async () => { + const { out, success } = await run(runRemoteCommand); + assert(out.length > 0); +// assertEquals(code, 17); + assertEquals(success, false); +}); diff --git a/test/fetchBlock.test.ts b/test/fetchBlock.test.ts new file mode 100644 index 0000000..64b5bbe --- /dev/null +++ b/test/fetchBlock.test.ts @@ -0,0 +1,173 @@ +import { + assertEquals, + assertRejects, +} from "https://deno.land/std@0.160.0/testing/asserts.ts"; +import { consumeBodies, fetchBlock } from "../src/fetchBlock.ts"; +import { stub } from "https://deno.land/std@0.160.0/testing/mock.ts"; +import { parseBlockText } from "../src/parseBlockText.ts"; +import { Block } from "../src/types.ts"; +Deno.env.get("NO_LOG") && stub(console, "info"); + +const HOST = Deno.env.get("HOST") || "https://faker.deno.dev"; +const HOST_HTTPBIN = Deno.env.get("HOST_HTTPBIN") || "http://httpbin.org"; + +Deno.test( + "[fetchBlock] with expectedResponse and actualResponse", // { only: true }, + async () => { + const block = new Block({ + meta: {}, + text: ` + GET ${HOST_HTTPBIN}/status/400 + + HTTP/1.1 403 Forbidden + `, + }); + await parseBlockText(block); + await fetchBlock(block); + assertEquals(block.expectedResponse?.status, 403); + assertEquals(block.actualResponse?.status, 400); + await consumeBodies(block); + }, +); + +Deno.test( + "[fetchBlock] with expectedResponse and actualResponse", // { only: true }, + async () => { + const block = new Block({ + meta: {}, + text: ` +GET ${HOST_HTTPBIN}/status/400 + +HTTP/1.1 400 Forbidden +`, + }); + await parseBlockText(block); + await fetchBlock(block); + assertEquals(block.expectedResponse?.statusText, "Forbidden"); + assertEquals(block.actualResponse?.statusText, "Bad Request"); + await consumeBodies(block); + }, +); + +Deno.test( + "[fetchBlock] with expectedResponse and actualResponse", // { only: true }, + async () => { + const block = new Block({ + meta: {}, + text: ` +GET ${HOST_HTTPBIN}/status/400 + +HTTP/1.1 400 Forbidden +`, + }); + await parseBlockText(block); + await fetchBlock(block); + assertEquals(block.expectedResponse?.statusText, "Forbidden"); + assertEquals(block.actualResponse?.statusText, "Bad Request"); + await consumeBodies(block); + }, +); + +Deno.test("[fetchBlock] with expectedResponse plain test body", async () => { + const block = new Block({ + meta: {}, + text: ` + POST ${HOST_HTTPBIN}/text + Content-Type: text/plain + + hola mundo + + HTTP/1.1 200 OK + Content-Type: text/plain + + hola mundo + + `, + }); + await parseBlockText(block); + const { expectedResponse } = await fetchBlock(block); + await consumeBodies(block); + assertEquals(expectedResponse?.status, 200); +}); + +Deno.test( + "[fetchBlock] with expectedResponse json body", // { only: true }, + // { ignore: true }, + async () => { + const block = new Block({ + meta: {}, + text: ` + POST ${HOST}/pong?quiet=true + Content-Type: application/json + + {"foo":"bar"} + + HTTP/1.1 200 OK + Content-Type: application/json + + {"foo":"bar"} + + `, + }); + await parseBlockText(block); + await fetchBlock(block); + await consumeBodies(block); + assertEquals(block.expectedResponse?.bodyRaw, '{"foo":"bar"}'); + assertEquals(block.expectedResponse?.status, 200); + // assertEquals(block.expectedResponse, block.actualResponse); + }, +); + +Deno.test( + "[fetchBlock] run block with request must throw error", // { only: true }, + // { ignore: true }, + async () => { + await assertRejects(async () => { + await fetchBlock(new Block({ text: "" })); + }); + }, +); + +// TODO rethink this + +// Deno.test("[fetchBlock] with response json body contains", +// // { only: true }, +// { ignore: true }, +// async () => { +// const response = await fetchBlock( +// ` +// POST ${HOST}/pong?quiet=true +// Content-Type: application/json + +// { "foo":"bar" , "bar": "foo" } + +// HTTP/1.1 200 OK +// Content-Type: application/json + +// {"foo":"bar"} + +// `); +// assertEquals(response?.status, 200); + +// }) + +// Deno.test("[fetchBlock] with response json body contains throws", +// // { only: true }, +// async () => { +// await assertRejects(async () => { +// await fetchBlock( +// ` +// POST ${HOST}/pong?quiet=true +// Content-Type: application/json + +// { "foo":"bar" , "bar": "foo" } + +// HTTP/1.1 200 OK +// Content-Type: application/json + +// {"foo":"bar", "b": "f"} + +// `); +// }); + +// }) diff --git a/test/filePathsToFiles.test.ts b/test/filePathsToFiles.test.ts new file mode 100644 index 0000000..1e9be26 --- /dev/null +++ b/test/filePathsToFiles.test.ts @@ -0,0 +1,22 @@ +import { assertEquals } from "https://deno.land/std@0.160.0/testing/asserts.ts"; +import { filePathsToFiles } from "../src/files.ts"; +import { Block } from "../src/types.ts"; +Deno.test("[filePathsToFiles] must not have request", async () => { + const files = await filePathsToFiles([`./http/test1.http`]); + assertEquals(files?.[0].blocks?.[0]?.request, undefined); +}); + +Deno.test("[filePathsToFiles] must have a basic block", async () => { + const files = await filePathsToFiles([`./http/test1.http`]); + assertEquals( + files?.[0].blocks?.[1], + new Block({ + meta: { + _startLine: 4, + _endLine: 7, + _filePath: "./http/test1.http", + }, + text: "\nGET /html\n\n###\n", + }), + ); +}); diff --git a/test/fileTextToBlocks.test.ts b/test/fileTextToBlocks.test.ts new file mode 100644 index 0000000..3c4c852 --- /dev/null +++ b/test/fileTextToBlocks.test.ts @@ -0,0 +1,31 @@ +import { assertEquals } from "https://deno.land/std@0.160.0/testing/asserts.ts"; +import { fileTextToBlocks } from "../src/files.ts"; +import { stub } from "https://deno.land/std@0.160.0/testing/mock.ts"; +Deno.env.get("NO_LOG") && stub(console, "info"); + +// const http = String.raw +Deno.test( + "[fileTextToBlocks]", // { only: true }, + () => { + const blocks = fileTextToBlocks( + ` +GET http://faker.deno.dev +### +GET http://faker.deno.dev + `, + "test.http", + ); + assertEquals(blocks.length, 2); + assertEquals(blocks[0].meta._startLine, 0); + assertEquals(blocks[0].meta._endLine, 2); + assertEquals(blocks[1].meta._startLine, 3); + assertEquals(blocks[1].meta._endLine, 4); + }, +); + +Deno.test("[fileTextToBlocks]", () => { + const blocks = fileTextToBlocks(`###`, "test.http"); + assertEquals(blocks.length, 1); + assertEquals(blocks[0].meta._startLine, 0); + assertEquals(blocks[0].meta._endLine, 0); +}); diff --git a/test/globsToFiles.test.ts b/test/globsToFiles.test.ts new file mode 100644 index 0000000..a20e762 --- /dev/null +++ b/test/globsToFiles.test.ts @@ -0,0 +1,29 @@ +import { assertEquals } from "https://deno.land/std@0.160.0/testing/asserts.ts"; +import { globsToFilePaths } from "../src/files.ts"; +// import { stub } from "https://deno.land/std@0.160.0/testing/mock.ts"; +// Deno.env.get('NO_LOG') && stub(console, 'info') + +Deno.test("[globsToFilePaths] find one file", async () => { + const files = await globsToFilePaths([`http/test1.http`]); + assertEquals(files.length, 1); +}); + +Deno.test("[globsToFilePaths] find more file", async () => { + const files = await globsToFilePaths([`**/test1.http`, `*/test2.http`]); + assertEquals(files.length, 2); +}); + +Deno.test("[globsToFilePaths] find more file with a glob pattern", async () => { + const files = await globsToFilePaths([`../*/http/test*.http`]); + assertEquals(files.length, 2); +}); + +Deno.test("[globsToFilePaths] find more file with a glob pattern", async () => { + const files = await globsToFilePaths([`**/test*.http`]); + assertEquals(files.length, 2); +}); + +Deno.test("[globsToFilePaths] not found", async () => { + const files = await globsToFilePaths([`notFound.http`]); + assertEquals(files.length, 0); +}); diff --git a/test/parseBlockText.test.ts b/test/parseBlockText.test.ts new file mode 100644 index 0000000..8097805 --- /dev/null +++ b/test/parseBlockText.test.ts @@ -0,0 +1,412 @@ +import { + assertEquals, + assertRejects, +} from "https://deno.land/std@0.160.0/testing/asserts.ts"; +import { parseBlockText } from "../src/parseBlockText.ts"; +import { stub } from "https://deno.land/std@0.160.0/testing/mock.ts"; +import { YAMLError } from "https://deno.land/std@0.160.0/encoding/_yaml/error.ts"; +import { Block } from "../src/types.ts"; + +Deno.env.get("NO_LOG") && stub(console, "info"); + +// const http = String.raw +Deno.test("[parseBlockText request]", async () => { + const block = { + meta: {}, + text: ` +GET http://faker.deno.dev +`, + }; + const { request } = await parseBlockText(new Block(block)); + assertEquals(request?.method, "GET", "invalid method"); + assertEquals(request?.url, "http://faker.deno.dev/"); +}); + +Deno.test("[parseBlockText request] with headers", async () => { + const block = { + meta: {}, + text: `POST http://faker.deno.dev HTTP/1.1 + Host: faker.deno.dev + User-Agent: curl/7.64.1 + + x-foo: bar`, + }; + const { request } = await parseBlockText(new Block(block)); + assertEquals(request?.headers.get("Host"), "faker.deno.dev"); + assertEquals(request?.headers.get("User-Agent"), "curl/7.64.1"); + assertEquals(request?.headers.get("x-foo"), null); +}); + +Deno.test("[parseBlockText request] with headers not body", async () => { + const block = { + meta: {}, + text: `GET http://faker.deno.dev HTTP/1.1 + Host: http://faker.deno.dev`, + }; + const { request } = await parseBlockText(new Block(block)); + assertEquals(request?.bodyRaw, null); + assertEquals(request?.headers.get("host"), "http://faker.deno.dev"); +}); + +Deno.test("[parseBlockText request] with headers and comments", async () => { + const block = { + meta: {}, + text: `POST http://faker.deno.dev HTTP/1.1 +Host: faker.deno.dev +# x-foo: bar +User-Agent: curl/7.64.1 + +x-foo: bar`, + }; + const { request } = await parseBlockText(new Block(block)); + assertEquals(request?.headers.get("Host"), "faker.deno.dev"); + assertEquals(request?.headers.get("User-Agent"), "curl/7.64.1"); + assertEquals(request?.headers.get("x-foo"), null); +}); + +Deno.test("[parseBlockText request] without protocol", async () => { + const block = { + meta: {}, + text: `GET faker.deno.dev`, + }; + + const { request } = await parseBlockText(new Block(block)); + assertEquals(request?.url, "http://faker.deno.dev/"); +}); + +Deno.test("[parseBlockText request] with body", async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev + Content-Type: text/plain + + hola mundo`, + }; + const { request } = await parseBlockText(new Block(block)); + const body = await request?.text(); + assertEquals(body, "hola mundo"); +}); + +Deno.test("[parseBlockText request] with body no headers", async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev + + hola mundo`, + }; + const { request } = await parseBlockText(new Block(block)); + const body = await request?.text(); + assertEquals( + request?.headers.get("Content-Type"), + "text/plain;charset=UTF-8", + ); + assertEquals(body, "hola mundo"); +}); + +Deno.test("[parseBlockText request] with body raw", async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev + + hola mundo`, + }; + const { request } = await parseBlockText(new Block(block)); + const body = request?.bodyRaw; + assertEquals(body, "hola mundo"); +}); + +Deno.test( + "[parseBlockText request] with comments and body", // + async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev +# x-foo: bar +Content-Type: text/plain +# ups + +hola mundo +# adios + +hola + +HTTP/1.1 200 OK + `, + }; + const { request } = await parseBlockText(new Block(block)); + + const body = await request?.text(); + assertEquals(request?.headers.get("x-foo"), null); + assertEquals(body, "hola mundo\n# adios\n\nhola"); + }, +); + +Deno.test( + "[parseBlockText request] with comments and body and final separator", + // + async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev +# x-foo: bar +Content-Type: text/plain +# ups + +hola mundo +# adios + +hola + +### + `, + }; + const { request } = await parseBlockText(new Block(block)); + + const body = await request?.text(); + assertEquals(request?.headers.get("x-foo"), null); + assertEquals(body, "hola mundo\n# adios\n\nhola"); + }, +); + +Deno.test( + "[parseBlockText expectedResponse] with status", // + async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev/pong + Content-Type: text/plain + + hola mundo + + HTTP/1.1 200 OK + x-foo: bar + + hola mundo + + `, + }; + const { expectedResponse } = await parseBlockText(new Block(block)); + + assertEquals(expectedResponse?.status, 200); + }, +); + +Deno.test( + "[parseBlockText expectedResponse] with headers", // + async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev/pong + Content-Type: text/plain + +hola mundo + +HTTP/1.1 200 OK +x-foo: bar + +hola mundo + +`, + }; + const { expectedResponse } = await parseBlockText(new Block(block)); + + assertEquals(expectedResponse?.headers.get("x-foo"), "bar"); + }, +); + +Deno.test( + "[parseBlockText expectedResponse] with statusText", // + async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev/pong +Content-Type: text/plain + +hola mundo + +HTTP/1.1 200 OK +x-foo: bar + +hola mundo + +`, + }; + const { expectedResponse } = await parseBlockText(new Block(block)); + + assertEquals(expectedResponse?.statusText, "OK"); + }, +); + +Deno.test( + "[parseBlockText expectedResponse] with body ", // + async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev/pong + Content-Type: text/plain + + hola mundo + + HTTP/1.1 200 OK + x-foo: bar + + + hola mundo + + `, + }; + const { expectedResponse } = await parseBlockText(new Block(block)); + assertEquals(await expectedResponse?.text(), "hola mundo"); + }, +); + +Deno.test( + "[parseBlockText expectedResponse] without body ", // + async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev/pong + Content-Type: text/plain + + hola mundo + + HTTP/1.1 200 OK + x-foo: bar + `, + }; + const { expectedResponse } = await parseBlockText(new Block(block)); + assertEquals(await expectedResponse?.text(), ""); + }, +); + +Deno.test( + "[parseBlockText expectedResponse] with body multiline ", // + async () => { + const block = { + meta: {}, + text: `POST faker.deno.dev/pong +Content-Type: text/plain + +hello world + +HTTP/1.1 200 OK +x-foo: bar +Content-Type: text/plain + + +hola +# hello + +mundo +### + + `, + }; + const { expectedResponse } = await parseBlockText(new Block(block)); + assertEquals(await expectedResponse?.text(), "hola\n# hello\n\nmundo"); + }, +); + +Deno.test("[parseBlockText meta] with front matter yml ", async () => { + const block = { + meta: {}, + text: ` +--- +name: test +description: hello world +null: +null2: null + +boolean: true +booleanFalse: false + +number: 1 +array: [1,2,3] +obj: {a: 1} +obj2: + a: 2 +list: + - 1 + - a +--- + +GET faker.deno.dev +`, + }; + const { meta } = await parseBlockText(new Block(block)); + + assertEquals(meta, { + name: "test", + description: "hello world", + null: null, + null2: null, + boolean: true, + booleanFalse: false, + array: [1, 2, 3], + number: 1, + obj: { a: 1 }, + list: [1, "a"], + obj2: { a: 2 }, + }); +}); + +Deno.test("[parseBlockText meta] with interpolation", async () => { + const block = { + meta: {}, + text: ` +--- +number: <%= 1 + 1 %> +--- + +GET faker.deno.dev +# hola + +### +`, + }; + const { meta } = await parseBlockText(new Block(block)); + + assertEquals(meta, { + number: 2, + }); +}); + +Deno.test("[parseBlockText meta] with comments", async () => { + const block = { + meta: {}, + text: ` +--- +number: 100 # this is a comment +ups: text # this is a comment +textWithComment: "hello # this is a comment" +--- + +GET faker.deno.dev +`, + }; + const { meta } = await parseBlockText(new Block(block)); + + assertEquals(meta, { + number: 100, + ups: "text", + textWithComment: "hello # this is a comment", + }); +}); + +Deno.test("[parseBlockText meta] fail parsing", async () => { + const block = { + meta: {}, + text: ` +--- +number: 100 +ups text # this is a comment +--- + +GET faker.deno.dev +`, + }; + await assertRejects( + async () => await parseBlockText(new Block(block)), + YAMLError, + ); +}); diff --git a/test/runner.test.ts b/test/runner.test.ts new file mode 100644 index 0000000..1b0b0e4 --- /dev/null +++ b/test/runner.test.ts @@ -0,0 +1,279 @@ +import { + assert, + assertEquals, + assertStringIncludes, +} from "https://deno.land/std@0.160.0/testing/asserts.ts"; +import { runner } from "../src/runner.ts"; + +const HOST = Deno.env.get("HOST") || "https://faker.deno.dev"; +const HOST_HTTPBIN = Deno.env.get("HOST_HTTPBIN") || "http://httpbin.org"; + +// console.debug(`HOST: ${HOST}`); +// console.debug(`HOST_HTTPBIN: ${HOST_HTTPBIN}`); + +Deno.test("[runner] find one file", async () => { + const { files } = await runner(["http/test1.http"], { display: "none" }); + assertEquals(files.length, 1); + assertEquals(files[0].blocks.length, 6); +}); + +Deno.test( + "[runner] must have found request, expected response, meta and actualResponse", + async () => { + const { files } = await runner(["http/test2.http"], { + display: "none", + }); + const firstBlock = files[0].blocks[1]; + assertEquals( + firstBlock.request?.url, + HOST + "/pong?quiet=true&delay=0", + ); + assertEquals(firstBlock.meta.boolean, true); + assertEquals(firstBlock.actualResponse?.status, 200); + assertEquals(firstBlock.expectedResponse?.status, 200); + }, +); + +Deno.test("[runner] interpolation", async () => { + const { files } = await runner(["http/interpolate.http"], { + display: "none", + }); + const firstBlock = files[0].blocks[1]; + + assertEquals( + firstBlock.expectedResponse?.headers.get("content-type"), + "text/plain;charset=UTF-8", + ); + assertEquals(await firstBlock.actualResponse?.getBody(), "Hola Garn!"); + const secondBlock = files[0].blocks[1 + 1]; + assertEquals(secondBlock.request?.headers.get("read-from-name"), "Garn"); + assertEquals(secondBlock.expectedResponse?.body, undefined); + + const thirdBlock = files[0].blocks[1 + 2]; + assertEquals(await thirdBlock.expectedResponse?.getBody(), undefined); + assertEquals(thirdBlock.error?.message, "ups"); + const fourthBlock = files[0].blocks[1 + 3]; + assertEquals(await fourthBlock.expectedResponse?.getBody(), "hola"); + assertEquals(fourthBlock.error, undefined); + + const fifthBlock = files[0].blocks[1 + 4]; + assertEquals(fifthBlock.request?.headers.get("x-payload"), "Garn?"); + assertEquals(fifthBlock.error, undefined); + assertEquals(await fifthBlock.expectedResponse?.getBody(), "Garn!"); + + const sixthBlock = files[0].blocks[1 + 5]; + assertEquals(sixthBlock.error, undefined); + assertEquals(await sixthBlock.request?.getBody(), "body"); + assertEquals(await sixthBlock.expectedResponse?.getBody(), "body"); + + const seventhBlock = files[0].blocks[1 + 6]; + assertEquals(seventhBlock.expectedResponse?.status, 200); + assertEquals(seventhBlock.expectedResponse?.statusText, "OK"); + assertEquals(seventhBlock.expectedResponse?.headers.get("hola"), "mundo"); + assertEquals(seventhBlock.expectedResponse?.headers.get("adios"), "mundo"); + + const eighthBlock = files[0].blocks[1 + 7]; + assertEquals(eighthBlock.meta.name, "must interpolate ts"); + assertEquals(eighthBlock.error, undefined); + assertEquals(await eighthBlock.request?.getBody(), "1"); +}); + +Deno.test("[runner] asserts ", async () => { + const { files } = await runner(["http/assert.http"], { + display: "none", + }); + const firstBlock = files[0].blocks[1]; + assertEquals(firstBlock.error?.name, "ExpectedResponseError"); + const secondBlock = files[0].blocks[1 + 1]; + assertEquals(secondBlock.error, undefined); + const thirdBlock = files[0].blocks[1 + 2]; + assertEquals(thirdBlock.error?.message, "failed!"); + const fourthBlock = files[0].blocks[1 + 3]; + assertStringIncludes( + fourthBlock.error?.message || "", + "Values are not equal", + ); +}); + +Deno.test("[runner] host meta data", async () => { + const { files } = await runner(["http/host.http"], { display: "none" }); + + const firstBlock = files[0].blocks[0]; + assertEquals(firstBlock.meta.host, HOST); + + const secondBlock = files[0].blocks[1]; + assertEquals( + secondBlock.request?.url, + HOST + "/pong?quiet=true", + ); + + const thirdBlock = files[0].blocks[2]; + assertEquals(thirdBlock.request?.url, HOST + "/", "thirdBlock"); + + const fourthBlock = files[0].blocks[3]; + assertEquals(fourthBlock.request?.url, HOST + "/ping", "fourthBlock"); + + const fifthBlock = files[0].blocks[4]; + assertEquals(fifthBlock.request?.url, HOST_HTTPBIN + "/get", "fifthBlock"); + + const sixthBlock = files[0].blocks[5]; + assertEquals(sixthBlock.request?.url, HOST_HTTPBIN + "/post", "sixthBlock"); +}); + +Deno.test("[runner] timeout", async () => { + const { files } = await runner(["http/timeout.http"], { + display: "none", + timeout: 100, + }); + + const firstBlock = files[0].blocks[1]; + assertEquals(firstBlock.meta.timeout, 100); + assertEquals(firstBlock.error?.message, "Timeout of 100ms exceeded"); + + const secondBlock = files[0].blocks[2]; + assertEquals(secondBlock.meta.timeout, 0); + assertEquals(secondBlock.error, undefined); + + const thirdBlock = files[0].blocks[3]; + assertEquals(thirdBlock.meta.timeout, 100); + assertEquals(thirdBlock.error?.message, "Timeout of 100ms exceeded"); +}); + +Deno.test("[runner] ref", async () => { + const { files } = await runner(["http/ref.http"], { + display: "none", + }); + + const firstBlock = files[0].blocks[1]; + assertEquals(firstBlock.meta.name, "block1"); + assertEquals(firstBlock.meta._isDoneBlock, true); + assertEquals(firstBlock.error, undefined); + assertEquals(await firstBlock.request?.getBody(), "RESPONSE!?"); +}); + +Deno.test("[runner] ref loop", async () => { + const { files } = await runner(["http/ref.loop.http"], { + display: "none", + }); + + const firstBlock = files[0].blocks[1]; + assertEquals(firstBlock.meta.name, "block1"); + assertEquals(firstBlock.meta._isDoneBlock, true); + assertEquals(firstBlock.error, undefined); + assertEquals(await firstBlock.request?.getBody(), "block2?"); + + const secondBlock = files[0].blocks[1 + 1]; + assertEquals(secondBlock.meta.name, "block2"); + assertEquals(secondBlock.meta._isDoneBlock, true); + assertEquals(secondBlock.error, undefined); + assertEquals(await secondBlock.request?.getBody(), "block1??"); +}); + +Deno.test( + "[runner] redirect ", + async () => { + const { files } = await runner(["http/redirect.http"], { + display: "none", + }); + + const firstBlock = files[0].blocks[1]; + assertEquals(firstBlock.request?.url, HOST + "/image/avatar"); + assertEquals(firstBlock.meta?.redirect, "follow"); + assertEquals( + firstBlock.response?.headers.get("content-type"), + "image/jpeg", + ); + assertEquals( + firstBlock.response?.redirected, + false, + "NOT REDIRECTED BECAUSE WHERE ARE EVALUATING THE FINAL RESPONSE", + ); + assertEquals(firstBlock.response?.status, 200); + assertEquals(firstBlock.response?.type, "default"); + + const secondBlock = files[0].blocks[2]; + assertEquals(secondBlock.response?.type, "default"); + assertEquals(secondBlock.meta?.redirect, "manual"); + assertEquals(secondBlock.response?.redirected, false); + assertEquals( + secondBlock.response?.headers.get("content-type"), + "application/json; charset=utf-8", + ); + assertEquals(secondBlock.response?.status, 307); + assertEquals( + secondBlock.response?.headers.get("Location")?.startsWith("http"), + true, + ); + }, +); + +Deno.test( + "[runner] only mode", + async () => { + Deno.env.set("TEST_ONLY", "true"); + const { files, exitCode, onlyMode } = await runner(["http/only.http"], { + display: "none", + }); + assertEquals(files[0].blocks.length, 3); + assertEquals(files[0].blocks[1].meta.ignore, true); + assertEquals(files[0].blocks[1].meta.only, undefined); + + assertEquals(files[0].blocks[2].meta.ignore, undefined); + assertEquals(files[0].blocks[2].meta.only, true); + + assertEquals(exitCode, 0); + assertEquals(onlyMode, new Set(["http/only.http:13"])); + }, +); + +Deno.test( + "[runner] global vars", + async () => { + const { files } = await runner(["http/globalVars.http"], { + display: "none", + }); + assertEquals(files[0].blocks.length, 3); + assertEquals(files[0].blocks[1].error, undefined); + assertEquals( + files[0].blocks[1].request?.url, + "https://faker.deno.dev/?body=2", + ); + + assertEquals(files[0].blocks[2].error, undefined); + assertEquals( + files[0].blocks[2].request?.url, + "https://faker.deno.dev/?body=2", + ); + }, +); + +Deno.test( + "[runner] meta.import must import", + async () => { + const { files, exitCode } = await runner([ + Deno.cwd() + "/http/import.http" + ], { + display: "default", + }); + assert(files.some(f => f.path.includes('import.http'))); + assert(files.some(f => f.path.includes('pass.http'))); + assertEquals(exitCode, 0) + }, +); + + +Deno.test( + "[runner] meta.import must run imported files before actual file without using ref", + { only: true }, + async () => { + const { files, exitCode } = await runner([ + Deno.cwd() + "/http/import.http", + Deno.cwd() + "/http/pass.http" + ], { + display: "none", + }); + assert(files.some(f => f.path.includes('import.http'))); + assert(files.some(f => f.path.includes('pass.http'))); + assertEquals(exitCode, 0) + }, +);