@dogfood-lab/verify 1.8.0 → 1.10.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/README.md +21 -17
- package/cli-lint.js +81 -36
- package/cli.js +99 -15
- package/index.js +205 -61
- package/package.json +1 -1
- package/parse-rejection.js +125 -27
- package/validators/f-2a5ddafa-provenance-retry-warn.test.js +226 -0
- package/validators/f-5fd3f832-retry-after-clamp.test.js +96 -0
- package/validators/f-ad98b5ac-required-steps-map-guard.test.js +85 -0
- package/validators/f-dac7e08c-transport-error-operational.test.js +121 -0
- package/validators/f-f50e779b-retry-warn-fallback-guard.test.js +153 -0
- package/validators/lint-scenario.js +141 -0
- package/validators/policy.js +31 -8
- package/validators/provenance-gitlab.test.js +10 -3
- package/validators/provenance-registry.test.js +35 -0
- package/validators/provenance.js +183 -17
- package/validators/repo-binding.js +35 -12
- package/validators/repo-binding.test.js +40 -0
- package/validators/steps.js +116 -9
- package/validators/verdict.js +9 -1
- package/validators/w4-f-8e72d0de-timeout-retry.test.js +118 -0
|
@@ -29,6 +29,19 @@ const schemaPath = require.resolve(
|
|
|
29
29
|
const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
30
30
|
const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* F-a2fb624f: sibling table to provenance-registry.test.js's — the pre-fix
|
|
34
|
+
* tripwire here ('returns null for a provider with no parser') only probed
|
|
35
|
+
* 'bitbucket', a NON-prototype key, so it gave zero coverage for the actual
|
|
36
|
+
* F-7ce07baa defect class (every Object.prototype own-property name, which
|
|
37
|
+
* `RUN_URL_PARSERS[provider]` resolved as a truthy inherited method before
|
|
38
|
+
* the Object.hasOwn fix).
|
|
39
|
+
*/
|
|
40
|
+
const OBJECT_PROTOTYPE_KEYS = [
|
|
41
|
+
'constructor', 'toString', 'valueOf', '__proto__',
|
|
42
|
+
'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString',
|
|
43
|
+
];
|
|
44
|
+
|
|
32
45
|
describe('repo-binding guard coverage (verify-B-001)', () => {
|
|
33
46
|
it('exposes the provider enum it is meant to cover (sanity)', () => {
|
|
34
47
|
assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
|
|
@@ -115,4 +128,31 @@ describe('repo-binding guard coverage (verify-B-001)', () => {
|
|
|
115
128
|
it('returns null for a provider with no parser (no throw)', () => {
|
|
116
129
|
assert.equal(parseRunUrlRepo('bitbucket', 'https://bitbucket.org/a/b/pipelines/1'), null);
|
|
117
130
|
});
|
|
131
|
+
|
|
132
|
+
describe('F-7ce07baa: parseRunUrlRepo never resolves an inherited Object.prototype key', () => {
|
|
133
|
+
for (const key of OBJECT_PROTOTYPE_KEYS) {
|
|
134
|
+
it(`returns null (not the inherited method) for provider="${key}"`, () => {
|
|
135
|
+
// Deletion/emptiness proof: revert parseRunUrlRepo to a bare
|
|
136
|
+
// `RUN_URL_PARSERS[provider]` lookup and this goes red for every key
|
|
137
|
+
// here — each currently resolves a truthy inherited Object.prototype
|
|
138
|
+
// value the `if (!parser)` guard cannot catch, and `parser(runUrl)`
|
|
139
|
+
// then calls an unrelated built-in method.
|
|
140
|
+
assert.equal(
|
|
141
|
+
parseRunUrlRepo(key, 'https://github.com/acme/widget/actions/runs/1'),
|
|
142
|
+
null,
|
|
143
|
+
`provider="${key}" must resolve to null, not an inherited Object.prototype member`
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('a non-string provider (object, number, null, undefined) never throws and returns null', () => {
|
|
149
|
+
for (const bad of [{}, 42, null, undefined, ['github']]) {
|
|
150
|
+
assert.equal(
|
|
151
|
+
parseRunUrlRepo(bad, 'https://github.com/acme/widget/actions/runs/1'),
|
|
152
|
+
null,
|
|
153
|
+
`provider=${JSON.stringify(bad)} must resolve to null`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
118
158
|
});
|
package/validators/steps.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* Validate step results for a single scenario result.
|
|
11
11
|
*
|
|
12
12
|
* Note: Without access to the source repo's scenario definition, we validate
|
|
13
|
-
* structural integrity. The full required_steps check is done by
|
|
14
|
-
*
|
|
13
|
+
* structural integrity. The full required_steps check is done by
|
|
14
|
+
* validateRequiredSteps below, which verify() runs per scenario_result when
|
|
15
|
+
* the caller supplies loaded scenario definitions (options.scenarios).
|
|
15
16
|
*
|
|
16
17
|
* @param {object} scenarioResult - A single scenario_results[] item
|
|
17
18
|
* @returns {string[]} Array of error messages (empty if valid)
|
|
@@ -41,6 +42,10 @@ export function validateStepResults(scenarioResult) {
|
|
|
41
42
|
const seenIds = new Set();
|
|
42
43
|
for (const step of step_results) {
|
|
43
44
|
if (step == null || typeof step !== 'object') continue;
|
|
45
|
+
// F-70338558: a step without a string step_id is already reported as
|
|
46
|
+
// malformed above; letting `undefined` into the dedupe Set made two
|
|
47
|
+
// malformed steps emit a spurious `duplicate step_id: undefined`.
|
|
48
|
+
if (typeof step.step_id !== 'string') continue;
|
|
44
49
|
if (seenIds.has(step.step_id)) {
|
|
45
50
|
errors.push(`duplicate step_id: ${step.step_id}`);
|
|
46
51
|
}
|
|
@@ -67,12 +72,63 @@ export function validateStepResults(scenarioResult) {
|
|
|
67
72
|
}
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
// F-88fb37ff: mirror of the pass-direction check above. A scenario cannot
|
|
76
|
+
// claim "fail"/"blocked" while every reported step says otherwise either —
|
|
77
|
+
// computeVerdict() (validators/verdict.js) trusts scenario_results[].verdict
|
|
78
|
+
// verbatim and never re-derives it from step_results, so without this check
|
|
79
|
+
// a self-reported "blocked" verdict backed by zero failing/blocked steps
|
|
80
|
+
// sailed through with no rejection reason at all.
|
|
81
|
+
//
|
|
82
|
+
// F-e42e8f80 (wave 20, amends F-88fb37ff): the original gate required at
|
|
83
|
+
// least one step to ACTIVELY report fail/blocked, which wrongly rejected
|
|
84
|
+
// the common honest shape "the scenario was blocked before any step could
|
|
85
|
+
// run, so every step reports 'skip'" — 'skip' and 'partial' are NEUTRAL
|
|
86
|
+
// (no evidence either way), not "evidence of no failure." Only 'pass' is an
|
|
87
|
+
// ACTIVE CONTRADICTION of a fail/blocked verdict (the step ran and claimed
|
|
88
|
+
// success). The gate now fires only when EVERY reported step actively says
|
|
89
|
+
// "pass" — a single skip/partial/fail/blocked step is enough to keep a
|
|
90
|
+
// fail/blocked verdict internally consistent.
|
|
91
|
+
//
|
|
92
|
+
// F-cc198701 (wave 22, confirming audit of F-e42e8f80): this condition and
|
|
93
|
+
// its validateRequiredSteps mirror below enumerated only 2 of the 4 legal
|
|
94
|
+
// values in scenario_results[].verdict's own schema enum (dogfood-record-
|
|
95
|
+
// submission.schema.json: ["pass","fail","blocked","partial"]) — 'partial'
|
|
96
|
+
// was never checked in either direction. A submitter could self-report
|
|
97
|
+
// verdict:'partial' with EVERY step actively 'fail' (strictly worse,
|
|
98
|
+
// more self-contradictory evidence than the fail/blocked direction already
|
|
99
|
+
// rejects) and sail through with zero rejection reason, because
|
|
100
|
+
// computeVerdict() never re-derives the verdict from step_results either —
|
|
101
|
+
// the ONLY guard against a dishonest self-report was this exact check, and
|
|
102
|
+
// it had a verdict-enum-shaped hole. Widened to also fire for 'partial',
|
|
103
|
+
// reusing the identical "all present steps actively pass" bar: 'partial'
|
|
104
|
+
// backed by zero non-pass evidence is exactly as self-contradictory as
|
|
105
|
+
// 'blocked'/'fail' backed by zero non-pass evidence. Deliberately does NOT
|
|
106
|
+
// touch the pass-direction check above (line ~59) — 'partial' backed by
|
|
107
|
+
// SOME failing steps is not inherently contradictory the way 'partial'
|
|
108
|
+
// backed by all-pass steps is.
|
|
109
|
+
if (verdict === 'fail' || verdict === 'blocked' || verdict === 'partial') {
|
|
110
|
+
const allStepsActivelyPass = step_results.every(
|
|
111
|
+
s => s != null && s.status === 'pass'
|
|
112
|
+
);
|
|
113
|
+
if (allStepsActivelyPass) {
|
|
114
|
+
errors.push(
|
|
115
|
+
`scenario verdict is "${verdict}" but no step reports status fail/blocked`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
70
120
|
return errors;
|
|
71
121
|
}
|
|
72
122
|
|
|
73
123
|
/**
|
|
74
124
|
* Validate step results against a scenario definition's required_steps.
|
|
75
|
-
*
|
|
125
|
+
*
|
|
126
|
+
* F-3bfc2885: this is the REAL enforcement behind the `step-results-present`
|
|
127
|
+
* and `step-verdict-consistent` global reject rules (policies/global-policy.yaml,
|
|
128
|
+
* allowlisted in KNOWN_REJECT_RULE_IDS). verify() calls it per scenario_result
|
|
129
|
+
* when the caller supplies loaded scenario definitions (options.scenarios).
|
|
130
|
+
* Each error is tagged with the `[rule-id]` it enforces so operators can pivot
|
|
131
|
+
* from a rejection reason straight to the policy rule.
|
|
76
132
|
*
|
|
77
133
|
* @param {object} scenarioResult - A single scenario_results[] item
|
|
78
134
|
* @param {string[]} requiredSteps - Step IDs from scenario definition's success_criteria.required_steps
|
|
@@ -82,29 +138,80 @@ export function validateRequiredSteps(scenarioResult, requiredSteps) {
|
|
|
82
138
|
const errors = [];
|
|
83
139
|
const { step_results, verdict } = scenarioResult;
|
|
84
140
|
|
|
85
|
-
if (!step_results) return ['step_results missing'];
|
|
141
|
+
if (!step_results) return ['[step-results-present] step_results missing'];
|
|
86
142
|
|
|
87
|
-
|
|
143
|
+
// F-ad98b5ac: build the map defensively — the same floor the sibling
|
|
144
|
+
// validateStepResults applies (its verify-B-004 guard). A null / non-object /
|
|
145
|
+
// string-step_id-less element must not throw a TypeError here: that TypeError
|
|
146
|
+
// escapes to runValidator('steps', ...) which mislabels it as an operational
|
|
147
|
+
// VALIDATOR_FAULT_STEPS, inverting a submission-bad signal into an ops fault
|
|
148
|
+
// (the F-efe4f893 family inversion). Structural malformed-step *reporting*
|
|
149
|
+
// stays validateStepResults' job; this only stops the map build from throwing.
|
|
150
|
+
const resultMap = new Map();
|
|
151
|
+
for (const s of step_results) {
|
|
152
|
+
if (s != null && typeof s === 'object' && typeof s.step_id === 'string') {
|
|
153
|
+
resultMap.set(s.step_id, s);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
88
156
|
|
|
89
157
|
// Every required step must have a matching step_result
|
|
90
158
|
for (const stepId of requiredSteps) {
|
|
91
159
|
const result = resultMap.get(stepId);
|
|
92
160
|
if (!result) {
|
|
93
|
-
errors.push(`required step "${stepId}" has no matching step_result`);
|
|
161
|
+
errors.push(`[step-results-present] required step "${stepId}" has no matching step_result`);
|
|
94
162
|
}
|
|
95
163
|
}
|
|
96
164
|
|
|
97
|
-
// A scenario cannot be "pass" if any required step is fail/blocked
|
|
165
|
+
// A scenario cannot be "pass" if any required step is fail/blocked — or
|
|
166
|
+
// absent entirely: validateStepResults' structural check only sees REPORTED
|
|
167
|
+
// steps, so a submitter who silently omits a failing required step would
|
|
168
|
+
// otherwise keep a "pass" verdict consistent (F-3bfc2885 sibling case).
|
|
98
169
|
if (verdict === 'pass') {
|
|
99
170
|
for (const stepId of requiredSteps) {
|
|
100
171
|
const result = resultMap.get(stepId);
|
|
101
|
-
if (result
|
|
172
|
+
if (!result) {
|
|
102
173
|
errors.push(
|
|
103
|
-
`scenario verdict is "pass" but required step "${stepId}" has
|
|
174
|
+
`[step-verdict-consistent] scenario verdict is "pass" but required step "${stepId}" has no step_result`
|
|
175
|
+
);
|
|
176
|
+
} else if (result.status === 'fail' || result.status === 'blocked') {
|
|
177
|
+
errors.push(
|
|
178
|
+
`[step-verdict-consistent] scenario verdict is "pass" but required step "${stepId}" has status "${result.status}"`
|
|
104
179
|
);
|
|
105
180
|
}
|
|
106
181
|
}
|
|
107
182
|
}
|
|
108
183
|
|
|
184
|
+
// F-88fb37ff: mirror of the pass-direction block above, scoped to REQUIRED
|
|
185
|
+
// steps (the sibling check in validateStepResults enforces the same rule
|
|
186
|
+
// over ALL reported steps regardless of which are required).
|
|
187
|
+
//
|
|
188
|
+
// F-e42e8f80 (wave 20, amends F-88fb37ff): "no required step reports
|
|
189
|
+
// fail/blocked" was too narrow a bar — a required step honestly reporting
|
|
190
|
+
// 'skip' (never ran because the scenario was blocked upstream) or 'partial'
|
|
191
|
+
// is NEUTRAL, not an active contradiction, and must not force a rejection.
|
|
192
|
+
// The gate now fires only when every PRESENT required step actively says
|
|
193
|
+
// "pass" — mirroring validateStepResults' all-pass bar. A required step
|
|
194
|
+
// that is simply MISSING is "not an active pass" exactly like skip/partial
|
|
195
|
+
// would be, so it never by itself forces this check to fire (`result !=
|
|
196
|
+
// null && result.status === 'pass'` is false for a missing step too); its
|
|
197
|
+
// absence is already rejected unconditionally by the [step-results-present]
|
|
198
|
+
// loop above, regardless of verdict, so this check deliberately does not
|
|
199
|
+
// pile a second, verdict-specific error onto the exact same gap.
|
|
200
|
+
//
|
|
201
|
+
// F-cc198701 (wave 22): mirrors the identical widening in
|
|
202
|
+
// validateStepResults above — 'partial' was the one legal scenario-verdict
|
|
203
|
+
// enum value this scoped-to-required-steps check never enumerated either.
|
|
204
|
+
if ((verdict === 'fail' || verdict === 'blocked' || verdict === 'partial') && requiredSteps.length > 0) {
|
|
205
|
+
const allPresentRequiredStepsActivelyPass = requiredSteps.every(stepId => {
|
|
206
|
+
const result = resultMap.get(stepId);
|
|
207
|
+
return result != null && result.status === 'pass';
|
|
208
|
+
});
|
|
209
|
+
if (allPresentRequiredStepsActivelyPass) {
|
|
210
|
+
errors.push(
|
|
211
|
+
`[step-verdict-consistent] scenario verdict is "${verdict}" but no required step reports status fail/blocked`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
109
216
|
return errors;
|
|
110
217
|
}
|
package/validators/verdict.js
CHANGED
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
* Verdict severity (highest to lowest): fail > blocked > partial > pass
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// F-937733ee: family sibling of F-2965699b/F-7ce07baa. Object.create(null)
|
|
10
|
+
// removes the prototype chain entirely — `VERDICT_RANK['constructor']` is
|
|
11
|
+
// `undefined`, not `Object.prototype.constructor` — so the `== null` guards
|
|
12
|
+
// below fire correctly for every Object.prototype key, not just for a
|
|
13
|
+
// literal typo. Currently unreachable in production (index.js:326 only ever
|
|
14
|
+
// passes schema-enum-valid verdicts into `scenarioResults`), but sealing it
|
|
15
|
+
// here keeps a future caller that skips the schema gate from silently
|
|
16
|
+
// reopening the class.
|
|
17
|
+
const VERDICT_RANK = Object.assign(Object.create(null), { fail: 0, blocked: 1, partial: 2, pass: 3 });
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* Compute the verified verdict.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* w4-f-8e72d0de-timeout-retry.test.js
|
|
3
|
+
*
|
|
4
|
+
* F-8e72d0de (wave 4): both provenance adapters threw IMMEDIATELY on an
|
|
5
|
+
* AbortError (per-request timeout) while retrying transport rejects and
|
|
6
|
+
* 429/5xx within the PROVENANCE_RETRIES budget. A timeout is at least as
|
|
7
|
+
* transient as a connection refusal — a single slow response failed the
|
|
8
|
+
* whole confirmation when one retry might have confirmed the run. Contrast:
|
|
9
|
+
* the scenario fetcher in ingest/load-context.js already retried timeouts.
|
|
10
|
+
*
|
|
11
|
+
* Contract (both adapters — the documented peer discipline):
|
|
12
|
+
* - an AbortError is retried within the same budget as a transport reject;
|
|
13
|
+
* - on exhaustion the timeout error still throws (operational,
|
|
14
|
+
* `provenance-fault:` at the verify() boundary).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
|
|
20
|
+
import { githubProvenance, gitlabProvenance } from './provenance.js';
|
|
21
|
+
|
|
22
|
+
const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
|
|
23
|
+
|
|
24
|
+
const GH_SOURCE = {
|
|
25
|
+
provider: 'github',
|
|
26
|
+
workflow: 'dogfood.yml',
|
|
27
|
+
provider_run_id: '9123456789',
|
|
28
|
+
run_url: 'https://github.com/acme/widget/actions/runs/9123456789'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const GL_SOURCE = {
|
|
32
|
+
provider: 'gitlab',
|
|
33
|
+
workflow: '.gitlab-ci.yml',
|
|
34
|
+
provider_run_id: '424242',
|
|
35
|
+
run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242',
|
|
36
|
+
repo: 'acme/widget'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function abortError() {
|
|
40
|
+
const e = new Error('This operation was aborted');
|
|
41
|
+
e.name = 'AbortError';
|
|
42
|
+
return e;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const noSleep = async () => {};
|
|
46
|
+
|
|
47
|
+
describe('F-8e72d0de — githubProvenance retries per-request timeouts', () => {
|
|
48
|
+
it('a single timeout followed by a 200 confirms the run', async () => {
|
|
49
|
+
let calls = 0;
|
|
50
|
+
const adapter = githubProvenance('token', {
|
|
51
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
52
|
+
fetchImpl: async () => {
|
|
53
|
+
calls++;
|
|
54
|
+
if (calls === 1) throw abortError();
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
status: 200,
|
|
58
|
+
json: async () => ({
|
|
59
|
+
id: 9123456789,
|
|
60
|
+
status: 'completed',
|
|
61
|
+
head_sha: RUN_HEAD,
|
|
62
|
+
repository: { full_name: 'acme/widget' }
|
|
63
|
+
})
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const ok = await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
|
|
68
|
+
assert.equal(ok, true, 'one slow response must not fail the confirmation');
|
|
69
|
+
assert.equal(calls, 2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('exhausting every attempt on timeouts still throws the timeout error', async () => {
|
|
73
|
+
let calls = 0;
|
|
74
|
+
const adapter = githubProvenance('token', {
|
|
75
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
76
|
+
fetchImpl: async () => { calls++; throw abortError(); }
|
|
77
|
+
});
|
|
78
|
+
await assert.rejects(
|
|
79
|
+
() => adapter.confirm(GH_SOURCE),
|
|
80
|
+
/provenance: GitHub API timeout after 1000ms/
|
|
81
|
+
);
|
|
82
|
+
assert.equal(calls, 3, 'retries=2 → 3 total attempts before the operational throw');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('F-8e72d0de — gitlabProvenance mirrors the timeout-retry discipline', () => {
|
|
87
|
+
it('a single timeout followed by a 200 confirms the pipeline', async () => {
|
|
88
|
+
let calls = 0;
|
|
89
|
+
const adapter = gitlabProvenance('token', {
|
|
90
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
91
|
+
fetchImpl: async () => {
|
|
92
|
+
calls++;
|
|
93
|
+
if (calls === 1) throw abortError();
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
status: 200,
|
|
97
|
+
json: async () => ({ id: 424242, status: 'success', sha: RUN_HEAD })
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const ok = await adapter.confirm(GL_SOURCE, { refCommitSha: RUN_HEAD });
|
|
102
|
+
assert.equal(ok, true);
|
|
103
|
+
assert.equal(calls, 2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('exhausting every attempt on timeouts still throws the timeout error', async () => {
|
|
107
|
+
let calls = 0;
|
|
108
|
+
const adapter = gitlabProvenance('token', {
|
|
109
|
+
timeoutMs: 1000, retries: 1, sleepImpl: noSleep,
|
|
110
|
+
fetchImpl: async () => { calls++; throw abortError(); }
|
|
111
|
+
});
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
() => adapter.confirm(GL_SOURCE),
|
|
114
|
+
/provenance: GitLab API timeout after 1000ms/
|
|
115
|
+
);
|
|
116
|
+
assert.equal(calls, 2);
|
|
117
|
+
});
|
|
118
|
+
});
|