@specbook/core 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/assert.ts +17 -6
- package/src/config.ts +17 -2
- package/src/controls.ts +34 -9
- package/src/diff.ts +131 -0
- package/src/environment.ts +175 -0
- package/src/index.ts +14 -2
- package/src/mock.ts +80 -70
- package/src/oauth.ts +164 -0
- package/src/reporters/ci.ts +53 -0
- package/src/reporters/color.ts +34 -0
- package/src/reporters/envelope.ts +63 -0
- package/src/reporters/index.ts +139 -25
- package/src/reporters/list.ts +254 -0
- package/src/reporters/redact.ts +113 -0
- package/src/reporters/trace.ts +70 -0
- package/src/request.ts +20 -2
- package/src/runner/index.ts +1 -0
- package/src/runner/run.ts +113 -25
- package/src/runner/suite.ts +122 -0
- package/src/runner/transport.ts +29 -4
- package/src/secrets.ts +31 -2
- package/src/sequence.ts +4 -4
- package/src/spec-config.ts +211 -14
- package/src/types.ts +49 -32
package/src/mock.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// In-process mock transport:
|
|
1
|
+
// In-process mock transport: answers from the generated examples, filled in from the request that was sent, and lets a
|
|
2
|
+
// request's `withMock` hook change the answer.
|
|
2
3
|
import { reflectRequest } from './mock-reflect'
|
|
3
4
|
import type { PreparedRequest, RawResponse, Transport } from './runner/transport'
|
|
4
5
|
import { deepMerge, render } from './template'
|
|
5
|
-
import type { MockResponse } from './types'
|
|
6
6
|
|
|
7
7
|
export interface MockExampleLoader {
|
|
8
8
|
/** Statuses that have an example for this operation, in spec order. */
|
|
@@ -12,48 +12,35 @@ export interface MockExampleLoader {
|
|
|
12
12
|
|
|
13
13
|
export interface MockTransportOptions {
|
|
14
14
|
loader: MockExampleLoader
|
|
15
|
-
|
|
15
|
+
/** Waits a mock's `delayMs`. The default can be ended early by the run's signal: a person pressing Stop. */
|
|
16
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
const
|
|
19
|
-
v !== null &&
|
|
20
|
-
typeof v === 'object' &&
|
|
21
|
-
('data' in (v as object) ||
|
|
22
|
-
'init' in (v as object) ||
|
|
23
|
-
'headers' in (v as object) ||
|
|
24
|
-
'delayMs' in (v as object))
|
|
19
|
+
const is2xx = (s: number) => s >= 200 && s < 300
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
Object.entries(init ?? {}).map(([k, v]) => [
|
|
29
|
-
k,
|
|
30
|
-
typeof v === 'function' ? (v as () => unknown)() : v,
|
|
31
|
-
]),
|
|
32
|
-
)
|
|
33
|
-
}
|
|
21
|
+
/** The loader may hand out one object to every caller, and a hook edits what it is given. */
|
|
22
|
+
const copy = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T))
|
|
34
23
|
|
|
35
24
|
export function createMockTransport(opts: MockTransportOptions): Transport {
|
|
36
|
-
const sleep =
|
|
37
|
-
|
|
25
|
+
const sleep =
|
|
26
|
+
opts.sleep ??
|
|
27
|
+
((ms: number, signal?: AbortSignal) =>
|
|
28
|
+
new Promise<void>((done, stop) => {
|
|
29
|
+
if (signal?.aborted) return stop(new Error('Stopped before the API answered.'))
|
|
30
|
+
const timer = setTimeout(done, ms)
|
|
31
|
+
signal?.addEventListener(
|
|
32
|
+
'abort',
|
|
33
|
+
() => {
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
stop(new Error('Stopped before the API answered.'))
|
|
36
|
+
},
|
|
37
|
+
{ once: true },
|
|
38
|
+
)
|
|
39
|
+
}))
|
|
40
|
+
return async (req: PreparedRequest, signal?: AbortSignal): Promise<RawResponse> => {
|
|
38
41
|
const hints = req.mock
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const overridden = Object.keys(cfg?.responses ?? {}).map(Number)
|
|
42
|
-
const all = [...new Set([...known, ...overridden])]
|
|
43
|
-
|
|
44
|
-
const fromWhen = cfg?.when?.({
|
|
45
|
-
vars: (hints?.vars ?? {}) as never,
|
|
46
|
-
body: req.body,
|
|
47
|
-
query: hints?.rawQuery,
|
|
48
|
-
path: hints?.pathParams,
|
|
49
|
-
})
|
|
50
|
-
const status =
|
|
51
|
-
req.mockStatus ??
|
|
52
|
-
fromWhen ??
|
|
53
|
-
cfg?.status ??
|
|
54
|
-
all.find((s) => s >= 200 && s < 300) ??
|
|
55
|
-
all[0] ??
|
|
56
|
-
200
|
|
42
|
+
const all = await opts.loader.statuses(req.service, req.opId)
|
|
43
|
+
const status = req.mockStatus ?? all.find(is2xx) ?? all[0] ?? 200
|
|
57
44
|
|
|
58
45
|
// Secured operations answer 401 when no credential header was applied.
|
|
59
46
|
if (
|
|
@@ -67,42 +54,65 @@ export function createMockTransport(opts: MockTransportOptions): Transport {
|
|
|
67
54
|
return json(401, body)
|
|
68
55
|
}
|
|
69
56
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
57
|
+
const scope = {
|
|
58
|
+
...(hints?.vars ?? {}),
|
|
59
|
+
...(hints?.pathParams ?? {}),
|
|
60
|
+
...(hints?.rawQuery ?? {}),
|
|
61
|
+
}
|
|
75
62
|
// The generated example answers the request that was sent, so a created thing has the fields you posted.
|
|
76
|
-
if (status >= 200 && status < 300 && cfg?.reflect !== false)
|
|
77
|
-
template = reflectRequest(template, {
|
|
78
|
-
body: req.body,
|
|
79
|
-
path: hints?.pathParams,
|
|
80
|
-
query: hints?.rawQuery,
|
|
81
|
-
})
|
|
82
63
|
// A declared status without a response schema (e.g. Petstore's 404) has no example: empty body.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
64
|
+
const answer = async (s: number) => {
|
|
65
|
+
let template: unknown = copy(await opts.loader.example(req.service, req.opId, s))
|
|
66
|
+
if (is2xx(s))
|
|
67
|
+
template = reflectRequest(template, {
|
|
68
|
+
body: req.body,
|
|
69
|
+
path: hints?.pathParams,
|
|
70
|
+
query: hints?.rawQuery,
|
|
71
|
+
})
|
|
72
|
+
return template === undefined ? undefined : render(template, scope)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const res: {
|
|
76
|
+
status: number
|
|
77
|
+
data: unknown
|
|
78
|
+
headers: Record<string, string>
|
|
79
|
+
delayMs: number
|
|
80
|
+
respond(status: number, data?: unknown): void
|
|
81
|
+
} = {
|
|
82
|
+
status,
|
|
83
|
+
data: await answer(status),
|
|
84
|
+
headers: {},
|
|
85
|
+
delayMs: 0,
|
|
86
|
+
respond: (s, data) => {
|
|
87
|
+
pending = { status: s, data }
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
let pending: { status: number; data?: unknown } | undefined
|
|
91
|
+
|
|
92
|
+
if (hints?.hook) {
|
|
93
|
+
await hints.hook(
|
|
94
|
+
{
|
|
95
|
+
vars: (hints.vars ?? {}) as never,
|
|
96
|
+
body: req.body,
|
|
97
|
+
query: hints.rawQuery,
|
|
98
|
+
path: hints.pathParams,
|
|
99
|
+
headers: req.headers,
|
|
100
|
+
},
|
|
101
|
+
res as never,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (pending) {
|
|
105
|
+
const base = await answer(pending.status)
|
|
106
|
+
res.status = pending.status
|
|
107
|
+
res.data = pending.data === undefined ? base : deepMerge(base, pending.data)
|
|
95
108
|
}
|
|
96
|
-
if (delay) await sleep(delay)
|
|
97
|
-
const body = template === undefined ? undefined : render(template, scope)
|
|
98
|
-
return { ...json(status, body), headers: { 'content-type': 'application/json', ...headers } }
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
if (res.delayMs) await sleep(res.delayMs, signal)
|
|
111
|
+
return {
|
|
112
|
+
...json(res.status, res.data),
|
|
113
|
+
headers: { 'content-type': 'application/json', ...res.headers },
|
|
114
|
+
}
|
|
115
|
+
}
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
function json(status: number, body: unknown): RawResponse {
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 authorization code with PKCE (RFC 7636), as pure helpers over Web Crypto so the browser and
|
|
3
|
+
* Node run the same code. Nothing here stores anything: a verifier is held by the caller, in memory.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class OAuthError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message)
|
|
9
|
+
this.name = 'OAuthError'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The endpoints are written out: an OAuth 2.0 authorization code flow. */
|
|
14
|
+
export interface OAuthEndpoints {
|
|
15
|
+
authorizationUrl: string
|
|
16
|
+
tokenUrl: string
|
|
17
|
+
/** scope name -> description */
|
|
18
|
+
scopes?: Record<string, string>
|
|
19
|
+
/** a public identifier; pre-fills the sign-in prompt */
|
|
20
|
+
clientId?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The endpoints are found in an OpenID Connect discovery document. */
|
|
24
|
+
export interface OAuthDiscovery {
|
|
25
|
+
openIdConnectUrl: string
|
|
26
|
+
scopes?: Record<string, string>
|
|
27
|
+
clientId?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a spec or a config profile declares for an interactive sign-in. */
|
|
31
|
+
export type OAuthSettings = OAuthEndpoints | OAuthDiscovery
|
|
32
|
+
|
|
33
|
+
export const isDiscovery = (o: OAuthSettings): o is OAuthDiscovery => 'openIdConnectUrl' in o
|
|
34
|
+
|
|
35
|
+
/** Endpoints read from a discovery document. */
|
|
36
|
+
export interface DiscoveredEndpoints {
|
|
37
|
+
authorizationUrl: string
|
|
38
|
+
tokenUrl: string
|
|
39
|
+
/** scope name -> '' (a discovery document lists names only) */
|
|
40
|
+
scopes: Record<string, string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The two endpoints a sign-in needs, from `/.well-known/openid-configuration`. Only the access token is
|
|
45
|
+
* used, so nothing else in the document (ID-token signing keys, `nonce` support) matters here.
|
|
46
|
+
*/
|
|
47
|
+
export function parseDiscovery(json: unknown): DiscoveredEndpoints {
|
|
48
|
+
const d = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
|
|
49
|
+
const url = (v: unknown) => (typeof v === 'string' && /^https?:\/\//i.test(v) ? v : undefined)
|
|
50
|
+
const authorizationUrl = url(d.authorization_endpoint)
|
|
51
|
+
const tokenUrl = url(d.token_endpoint)
|
|
52
|
+
if (!authorizationUrl || !tokenUrl)
|
|
53
|
+
throw new OAuthError('The provider’s discovery document has no authorization or token endpoint')
|
|
54
|
+
const supported = Array.isArray(d.scopes_supported)
|
|
55
|
+
? d.scopes_supported.filter((x): x is string => typeof x === 'string')
|
|
56
|
+
: []
|
|
57
|
+
// `openid` is what makes it an OpenID Connect request; list it first even when the document omits it
|
|
58
|
+
const names = ['openid', ...supported.filter((x) => x !== 'openid')]
|
|
59
|
+
return { authorizationUrl, tokenUrl, scopes: Object.fromEntries(names.map((n) => [n, ''])) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const base64url = (bytes: Uint8Array): string => {
|
|
63
|
+
let bin = ''
|
|
64
|
+
for (const b of bytes) bin += String.fromCharCode(b)
|
|
65
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const random = (bytes: number) => base64url(crypto.getRandomValues(new Uint8Array(bytes)))
|
|
69
|
+
|
|
70
|
+
/** `code_challenge` for a verifier: BASE64URL(SHA-256(verifier)). */
|
|
71
|
+
export async function challengeFor(verifier: string): Promise<string> {
|
|
72
|
+
// WebCrypto's `subtle` exists only on a secure page (https, or localhost); a viewer served over plain
|
|
73
|
+
// http, or framed inside such a page, has none. Say so, rather than fail on `undefined.digest`.
|
|
74
|
+
if (!globalThis.crypto?.subtle)
|
|
75
|
+
throw new OAuthError(
|
|
76
|
+
'Sign-in needs a secure page (https, or localhost). Open the viewer over https, or paste an access token.',
|
|
77
|
+
)
|
|
78
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
79
|
+
return base64url(new Uint8Array(digest))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A fresh verifier (64 characters from the unreserved set) and its S256 challenge. */
|
|
83
|
+
export async function createPkce(): Promise<{ verifier: string; challenge: string }> {
|
|
84
|
+
const verifier = random(48)
|
|
85
|
+
return { verifier, challenge: await challengeFor(verifier) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const newState = (): string => random(24)
|
|
89
|
+
|
|
90
|
+
export function authorizeUrl(o: {
|
|
91
|
+
authorizationUrl: string
|
|
92
|
+
clientId: string
|
|
93
|
+
redirectUri: string
|
|
94
|
+
scopes?: string[]
|
|
95
|
+
state: string
|
|
96
|
+
challenge: string
|
|
97
|
+
}): string {
|
|
98
|
+
const url = new URL(o.authorizationUrl)
|
|
99
|
+
url.searchParams.set('response_type', 'code')
|
|
100
|
+
url.searchParams.set('client_id', o.clientId)
|
|
101
|
+
url.searchParams.set('redirect_uri', o.redirectUri)
|
|
102
|
+
if (o.scopes?.length) url.searchParams.set('scope', o.scopes.join(' '))
|
|
103
|
+
url.searchParams.set('state', o.state)
|
|
104
|
+
url.searchParams.set('code_challenge', o.challenge)
|
|
105
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
106
|
+
return url.toString()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The code from the redirect, once `state` has been checked; throws what the server or the check said. */
|
|
110
|
+
export function parseCallback(
|
|
111
|
+
params: URLSearchParams | Record<string, string | null | undefined>,
|
|
112
|
+
expectedState: string,
|
|
113
|
+
): { code: string } {
|
|
114
|
+
const get = (k: string) =>
|
|
115
|
+
(params instanceof URLSearchParams ? params.get(k) : params[k]) ?? undefined
|
|
116
|
+
const error = get('error')
|
|
117
|
+
if (error) {
|
|
118
|
+
const detail = get('error_description')
|
|
119
|
+
throw new OAuthError(`Sign-in was refused: ${detail ? `${error} (${detail})` : error}`)
|
|
120
|
+
}
|
|
121
|
+
if (get('state') !== expectedState)
|
|
122
|
+
throw new OAuthError('Sign-in was rejected: the response did not match this request (state)')
|
|
123
|
+
const code = get('code')
|
|
124
|
+
if (!code) throw new OAuthError('Sign-in returned no authorization code')
|
|
125
|
+
return { code }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The form fields of the code exchange. A public client sends the verifier and no client secret. */
|
|
129
|
+
export function tokenRequestFields(o: {
|
|
130
|
+
code: string
|
|
131
|
+
verifier: string
|
|
132
|
+
clientId: string
|
|
133
|
+
redirectUri: string
|
|
134
|
+
}): Record<string, string> {
|
|
135
|
+
return {
|
|
136
|
+
grant_type: 'authorization_code',
|
|
137
|
+
code: o.code,
|
|
138
|
+
redirect_uri: o.redirectUri,
|
|
139
|
+
client_id: o.clientId,
|
|
140
|
+
code_verifier: o.verifier,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const tokenRequestBody = (o: Parameters<typeof tokenRequestFields>[0]): string =>
|
|
145
|
+
new URLSearchParams(tokenRequestFields(o)).toString()
|
|
146
|
+
|
|
147
|
+
export function parseTokenResponse(json: unknown): {
|
|
148
|
+
accessToken: string
|
|
149
|
+
expiresIn?: number
|
|
150
|
+
scope?: string
|
|
151
|
+
} {
|
|
152
|
+
const r = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
|
|
153
|
+
if (typeof r.error === 'string') {
|
|
154
|
+
const detail = typeof r.error_description === 'string' ? ` (${r.error_description})` : ''
|
|
155
|
+
throw new OAuthError(`The token endpoint refused the code: ${r.error}${detail}`)
|
|
156
|
+
}
|
|
157
|
+
if (typeof r.access_token !== 'string' || r.access_token === '')
|
|
158
|
+
throw new OAuthError('The token endpoint returned no access token')
|
|
159
|
+
return {
|
|
160
|
+
accessToken: r.access_token,
|
|
161
|
+
...(typeof r.expires_in === 'number' ? { expiresIn: r.expires_in } : {}),
|
|
162
|
+
...(typeof r.scope === 'string' ? { scope: r.scope } : {}),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Where a run happens, from the environment a CI system sets, for the report's header and its links.
|
|
2
|
+
|
|
3
|
+
export interface CiInfo {
|
|
4
|
+
provider: string
|
|
5
|
+
runUrl?: string
|
|
6
|
+
sha?: string
|
|
7
|
+
branch?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function detectCi(env: Record<string, string | undefined>): CiInfo | undefined {
|
|
11
|
+
if (env.GITHUB_ACTIONS === 'true') {
|
|
12
|
+
const server = env.GITHUB_SERVER_URL ?? 'https://github.com'
|
|
13
|
+
return {
|
|
14
|
+
provider: 'github-actions',
|
|
15
|
+
runUrl:
|
|
16
|
+
env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID
|
|
17
|
+
? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`
|
|
18
|
+
: undefined,
|
|
19
|
+
sha: env.GITHUB_SHA,
|
|
20
|
+
branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (env.GITLAB_CI === 'true')
|
|
24
|
+
return {
|
|
25
|
+
provider: 'gitlab-ci',
|
|
26
|
+
runUrl: env.CI_PIPELINE_URL,
|
|
27
|
+
sha: env.CI_COMMIT_SHA,
|
|
28
|
+
branch: env.CI_COMMIT_REF_NAME,
|
|
29
|
+
}
|
|
30
|
+
if (env.CIRCLECI === 'true')
|
|
31
|
+
return {
|
|
32
|
+
provider: 'circleci',
|
|
33
|
+
runUrl: env.CIRCLE_BUILD_URL,
|
|
34
|
+
sha: env.CIRCLE_SHA1,
|
|
35
|
+
branch: env.CIRCLE_BRANCH,
|
|
36
|
+
}
|
|
37
|
+
if (env.BUILDKITE === 'true')
|
|
38
|
+
return {
|
|
39
|
+
provider: 'buildkite',
|
|
40
|
+
runUrl: env.BUILDKITE_BUILD_URL,
|
|
41
|
+
sha: env.BUILDKITE_COMMIT,
|
|
42
|
+
branch: env.BUILDKITE_BRANCH,
|
|
43
|
+
}
|
|
44
|
+
if (env.JENKINS_URL)
|
|
45
|
+
return {
|
|
46
|
+
provider: 'jenkins',
|
|
47
|
+
runUrl: env.BUILD_URL,
|
|
48
|
+
sha: env.GIT_COMMIT,
|
|
49
|
+
branch: env.GIT_BRANCH,
|
|
50
|
+
}
|
|
51
|
+
if (env.CI) return { provider: 'ci' }
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Terminal colors, only when they will be seen: a TTY or a CI log that renders them, not NO_COLOR, or FORCE_COLOR.
|
|
2
|
+
|
|
3
|
+
export interface Colors {
|
|
4
|
+
red(s: string): string
|
|
5
|
+
green(s: string): string
|
|
6
|
+
yellow(s: string): string
|
|
7
|
+
cyan(s: string): string
|
|
8
|
+
dim(s: string): string
|
|
9
|
+
bold(s: string): string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** CI systems whose logs render color though the stream is not a terminal. */
|
|
13
|
+
const COLOR_CI = ['GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'BUILDKITE']
|
|
14
|
+
|
|
15
|
+
/** Whether to color output: FORCE_COLOR wins, then NO_COLOR, then an interactive terminal or a CI log that shows color. */
|
|
16
|
+
export function colorEnabled(env: Record<string, string | undefined>, isTTY: boolean): boolean {
|
|
17
|
+
if (env.FORCE_COLOR && env.FORCE_COLOR !== '0') return true
|
|
18
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false
|
|
19
|
+
if (COLOR_CI.some((k) => env[k] && env[k] !== 'false')) return true
|
|
20
|
+
return isTTY && env.TERM !== 'dumb'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createColors(enabled: boolean): Colors {
|
|
24
|
+
const wrap = (open: number, close: number) => (s: string) =>
|
|
25
|
+
enabled ? `\u001b[${open}m${s}\u001b[${close}m` : s
|
|
26
|
+
return {
|
|
27
|
+
red: wrap(31, 39),
|
|
28
|
+
green: wrap(32, 39),
|
|
29
|
+
yellow: wrap(33, 39),
|
|
30
|
+
cyan: wrap(36, 39),
|
|
31
|
+
dim: wrap(2, 22),
|
|
32
|
+
bold: wrap(1, 22),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The JSON report: the results with what a monitoring system needs to know about the run around them.
|
|
2
|
+
|
|
3
|
+
import type { RunResult } from '../types'
|
|
4
|
+
import type { CiInfo } from './ci'
|
|
5
|
+
|
|
6
|
+
export interface ReportMeta {
|
|
7
|
+
env: string
|
|
8
|
+
/** version of the specbook that ran it */
|
|
9
|
+
specbook?: string
|
|
10
|
+
node?: string
|
|
11
|
+
os?: string
|
|
12
|
+
git?: { sha?: string; branch?: string }
|
|
13
|
+
ci?: CiInfo
|
|
14
|
+
/** `bundle.json` details when run from a bundle */
|
|
15
|
+
bundle?: { sha?: string; createdAt?: string }
|
|
16
|
+
/** service → origin of the address its calls went to */
|
|
17
|
+
services?: Record<string, string>
|
|
18
|
+
startedAt: string
|
|
19
|
+
finishedAt: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ReportTotals {
|
|
23
|
+
flows: { passed: number; failed: number; errored: number; flaky: number }
|
|
24
|
+
steps: { passed: number; failed: number; skipped: number }
|
|
25
|
+
assertions: { passed: number; failed: number }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RunReport extends ReportMeta {
|
|
29
|
+
schemaVersion: 2
|
|
30
|
+
durationMs: number
|
|
31
|
+
totals: ReportTotals
|
|
32
|
+
results: RunResult[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function reportTotals(results: RunResult[]): ReportTotals {
|
|
36
|
+
const t: ReportTotals = {
|
|
37
|
+
flows: { passed: 0, failed: 0, errored: 0, flaky: 0 },
|
|
38
|
+
steps: { passed: 0, failed: 0, skipped: 0 },
|
|
39
|
+
assertions: { passed: 0, failed: 0 },
|
|
40
|
+
}
|
|
41
|
+
for (const r of results) {
|
|
42
|
+
if (r.status === 'pass') t.flows.passed++
|
|
43
|
+
else if (r.status === 'error') t.flows.errored++
|
|
44
|
+
else t.flows.failed++
|
|
45
|
+
if (r.flaky) t.flows.flaky++
|
|
46
|
+
t.steps.passed += r.totals.passed
|
|
47
|
+
t.steps.failed += r.totals.failed
|
|
48
|
+
t.steps.skipped += r.totals.skipped
|
|
49
|
+
t.assertions.passed += r.totals.assertions.passed
|
|
50
|
+
t.assertions.failed += r.totals.assertions.failed
|
|
51
|
+
}
|
|
52
|
+
return t
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildReport(results: RunResult[], meta: ReportMeta): RunReport {
|
|
56
|
+
return {
|
|
57
|
+
schemaVersion: 2,
|
|
58
|
+
...meta,
|
|
59
|
+
durationMs: Math.max(0, Date.parse(meta.finishedAt) - Date.parse(meta.startedAt)),
|
|
60
|
+
totals: reportTotals(results),
|
|
61
|
+
results,
|
|
62
|
+
}
|
|
63
|
+
}
|