• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ekino / veggies / 14512275556

17 Apr 2025 09:13AM UTC coverage: 94.169% (-2.3%) from 96.46%
14512275556

push

github

web-flow
Merge pull request #96 from tduyng/2.x

A major rewrite for @ekino/veggies 2.0

576 of 616 branches covered (93.51%)

Branch coverage included in aggregate %.

801 of 847 new or added lines in 17 files covered. (94.57%)

829 of 876 relevant lines covered (94.63%)

67.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.54
/src/core/assertions.ts
1
import * as assert from 'node:assert/strict'
4✔
2
import type { MatchingRule, ObjectFieldSpec } from '../types.js'
3
import { getType, getValue, isEmpty, isNullish } from '../utils/index.js'
4✔
4
import { addTime, formatTime } from '../utils/time.js'
4✔
5
import * as Cast from './cast.js'
4✔
6

7
const negationRegex = `!|! |not |does not |doesn't |is not |isn't `
4✔
8
const matchRegex = new RegExp(`^(${negationRegex})?(match|matches|~=)$`)
4✔
9
const containRegex = new RegExp(`^(${negationRegex})?(contains?|\\*=)$`)
4✔
10
const startWithRegex = new RegExp(`^(${negationRegex})?(starts? with|\\^=)$`)
4✔
11
const endWithRegex = new RegExp(`^(${negationRegex})?(ends? with|\\$=)$`)
4✔
12
const presentRegex = new RegExp(`^(${negationRegex})?(defined|present|\\?)$`)
4✔
13
const equalRegex = new RegExp(`^(${negationRegex})?(equals?|=)$`)
4✔
14
const typeRegex = new RegExp(`^(${negationRegex})?(type|#=)$`)
4✔
15
const relativeDateRegex = new RegExp(`^(${negationRegex})?(equalRelativeDate)$`)
4✔
16
const relativeDateValueRegex = /^(\+?\d|-?\d),([A-Za-z]+),([A-Za-z-]{2,5}),(.+)$/
4✔
17

18
const RuleName = Object.freeze({
4✔
19
    Match: Symbol('match'),
4✔
20
    Contain: Symbol('contain'),
4✔
21
    StartWith: Symbol('startWith'),
4✔
22
    EndWith: Symbol('endWith'),
4✔
23
    Present: Symbol('present'),
4✔
24
    Equal: Symbol('equal'),
4✔
25
    Type: Symbol('type'),
4✔
26
    RelativeDate: Symbol('relativeDate'),
4✔
27
})
4✔
28

29
/**
30
 * Count object properties including nested objects ones.
31
 * If a property is an object, its key is ignored.
32
 *
33
 * @example
34
 * Assertions.countNestedProperties({
35
 *     a: true,
36
 *     b: true,
37
 *     c: true,
38
 * })
39
 * // => 3
40
 * Assertions.countNestedProperties({
41
 *     a: true,
42
 *     b: true,
43
 *     c: {
44
 *         a: true,
45
 *         b: true,
46
 *     },
47
 * })
48
 * // => 4 (c is ignored because it's a nested object)
49
 */
50
export const countNestedProperties = (object: Record<string, unknown>): number => {
4✔
51
    let propertiesCount = 0
48✔
52
    for (const key in object) {
48✔
53
        const val = object[key]
128✔
54
        if (!isEmpty(val) && typeof val === 'object') {
128✔
55
            const count = countNestedProperties(val as Record<string, unknown>)
16✔
56
            propertiesCount += count
16✔
57
        } else {
128✔
58
            propertiesCount++
112✔
59
        }
112✔
60
    }
128✔
61

62
    return propertiesCount
48✔
63
}
48✔
64

65
/**
66
 * Check that an object matches given specification.
67
 * specification must be defined as an array of ObjectFieldSpec.
68
 *
69
 * @example
70
 * Assertions.assertObjectMatchSpec(
71
 *     // object to check
72
 *     {
73
 *         first_name: 'Raoul',
74
 *         last_name: 'Marcel'
75
 *     },
76
 *     // spec
77
 *     [
78
 *         {
79
 *             field: 'first_name',
80
 *             matcher: 'equals',
81
 *             value: 'Raoul'
82
 *         },
83
 *         {
84
 *             field: 'last_name',
85
 *             matcher: 'equals',
86
 *             value: 'Dupond'
87
 *         },
88
 *     ]
89
 * )
90
 * // Will throw because last_name does not equal 'Dupond'.
91
 * [exact=false] - if `true`, specification must match all object's properties
92
 */
93

94
export const assertObjectMatchSpec = (
4✔
95
    object: Record<string, unknown>,
288✔
96
    spec: ObjectFieldSpec[],
288✔
97
    exact = false
288✔
98
): void => {
288✔
99
    for (const { field, matcher, value } of spec) {
288✔
100
        const currentValue = getValue(object, field) as string
548✔
101
        const expectedValue = Cast.getCastedValue(value) as string
548✔
102

103
        const rule = getMatchingRule(matcher)
548✔
104
        if (!rule) return
548✔
105

106
        const message = (msg: string) =>
544✔
107
            `Property '${field}' (${currentValue}) ${msg} '${expectedValue}'`
264✔
108

109
        switch (rule.name) {
544✔
110
            case RuleName.Match: {
548✔
111
                if (String(currentValue) === String(expectedValue)) {
68✔
NEW
112
                    rule.isNegated
×
NEW
113
                        ? assert.notEqual(currentValue, currentValue, message('matches'))
×
NEW
114
                        : assert.equal(currentValue, currentValue, message('does not match'))
×
115
                } else {
68✔
116
                    const regex = new RegExp(expectedValue)
68✔
117
                    rule.isNegated
68✔
118
                        ? assert.doesNotMatch(currentValue, regex, message('matches'))
20✔
119
                        : assert.match(currentValue, regex, message('does not match'))
48✔
120
                }
68✔
121
                break
44✔
122
            }
44✔
123
            case RuleName.Contain: {
548✔
124
                rule.isNegated
116✔
125
                    ? assert.ok(!currentValue.includes(expectedValue), message('contains'))
80✔
126
                    : assert.ok(currentValue.includes(expectedValue), message('does not contain'))
36✔
127
                break
116✔
128
            }
116✔
129
            case RuleName.StartWith: {
548✔
130
                rule.isNegated
40✔
131
                    ? assert.ok(!currentValue.startsWith(expectedValue), message('starts with'))
20✔
132
                    : assert.ok(
20✔
133
                          currentValue.startsWith(expectedValue),
20✔
134
                          message('does not start with')
20✔
135
                      )
20✔
136
                break
40✔
137
            }
40✔
138
            case RuleName.EndWith: {
548✔
139
                rule.isNegated
40✔
140
                    ? assert.ok(!currentValue.endsWith(expectedValue), message('ends with'))
20✔
141
                    : assert.ok(currentValue.endsWith(expectedValue), message('does not end with'))
20✔
142
                break
40✔
143
            }
40✔
144
            case RuleName.Present: {
548✔
145
                const messageErr = `Property '${field}' is ${rule.isNegated ? 'defined' : 'undefined'}`
124✔
146
                const value = isNullish(currentValue) ? 'defined' : 'undefined'
124✔
147
                rule.isNegated
124✔
148
                    ? assert.strictEqual(value, 'defined', messageErr)
84✔
149
                    : assert.strictEqual(value, 'undefined', messageErr)
40✔
150
                break
124✔
151
            }
124✔
152
            case RuleName.RelativeDate: {
548✔
153
                const match = relativeDateValueRegex.exec(expectedValue)
44✔
154
                if (isNullish(match)) throw new Error('relative date arguments are invalid')
44✔
155
                const [, amount, unit, locale, format] = match
40✔
156
                if (!locale || isNullish(amount) || !unit || !format) break
44!
157

158
                const normalizedLocale = Intl.getCanonicalLocales(locale)[0]
40✔
159

160
                if (!normalizedLocale) break
40!
161
                const now = new Date()
40✔
162
                const expectedDateObj = addTime(now, { unit, amount: Number(amount) })
40✔
163
                const expectedDate = formatTime(expectedDateObj, format, normalizedLocale)
40✔
164
                const messageErr = `Expected property '${field}' to ${
40✔
165
                    rule.isNegated ? 'not ' : ''
44✔
166
                }equal '${expectedDate}', but found '${currentValue}'`
44✔
167

168
                rule.isNegated
44✔
169
                    ? assert.notDeepStrictEqual(currentValue, expectedDate, messageErr)
20✔
170
                    : assert.deepStrictEqual(currentValue, expectedDate, messageErr)
20✔
171
                break
44✔
172
            }
44✔
173
            case RuleName.Type: {
548✔
174
                const messageErr = `Property '${field}' (${currentValue}) type is${
56✔
175
                    rule.isNegated ? '' : ' not'
56✔
176
                } '${expectedValue}'`
56✔
177

178
                const actualType = getType(currentValue)
56✔
179

180
                rule.isNegated
56✔
181
                    ? assert.notStrictEqual(actualType, expectedValue, messageErr)
12✔
182
                    : assert.strictEqual(actualType, expectedValue, messageErr)
44✔
183
                break
56✔
184
            }
56✔
185
            case RuleName.Equal: {
548✔
186
                const messageErr = `Expected property '${field}' to${
56✔
187
                    rule.isNegated ? ' not' : ''
56✔
188
                } equal '${value}', but found '${currentValue}'`
56✔
189

190
                rule.isNegated
56✔
191
                    ? assert.notDeepStrictEqual(currentValue, expectedValue, messageErr)
20✔
192
                    : assert.deepStrictEqual(currentValue, expectedValue, messageErr)
36✔
193
                break
56✔
194
            }
56✔
195
        }
548✔
196
    }
548✔
197

198
    // We check we have exactly the same number of properties as expected
199
    if (exact) {
288✔
200
        const propertiesCount = countNestedProperties(object)
8✔
201
        const message = 'Expected json response to fully match spec, but it does not'
8✔
202
        assert.strictEqual(propertiesCount, spec.length, message)
8✔
203
    }
8✔
204
}
288✔
205

206
/**
207
 * Get a rule matching the given matcher.
208
 * If it didn't match, it returns undefined.
209
 *
210
 * @example
211
 * Assertions.getMatchingRule(`doesn't match`)
212
 * // => { name: 'match', isNegated: true }
213
 * Assertions.getMatchingRule(`contains`)
214
 * // => { name: 'contain', isNegated: false }
215
 * Assertions.getMatchingRule(`unknown matcher`)
216
 * // => undefined
217
 */
218
export const getMatchingRule = (matcher?: string): MatchingRule | undefined => {
4✔
219
    if (!matcher) return assert.fail(`Matcher "${matcher}" must be defined`)
548!
220

221
    for (const { regex, name } of patterns) {
548✔
222
        const matchGroups = regex.exec(matcher)
2,312✔
223
        if (matchGroups) return { name, isNegated: !!matchGroups[1] }
2,312✔
224
    }
2,312✔
225

226
    return assert.fail(`Matcher "${matcher}" did not match any supported assertions`)
4✔
227
}
4✔
228

229
const patterns = [
4✔
230
    { regex: matchRegex, name: RuleName.Match },
4✔
231
    { regex: containRegex, name: RuleName.Contain },
4✔
232
    { regex: startWithRegex, name: RuleName.StartWith },
4✔
233
    { regex: endWithRegex, name: RuleName.EndWith },
4✔
234
    { regex: presentRegex, name: RuleName.Present },
4✔
235
    { regex: equalRegex, name: RuleName.Equal },
4✔
236
    { regex: typeRegex, name: RuleName.Type },
4✔
237
    { regex: relativeDateRegex, name: RuleName.RelativeDate },
4✔
238
]
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc