@alwaysmeticulous/cli 2.19.3 → 2.20.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/dist/api/replay.api.d.ts +1 -1
- package/dist/api/session.api.d.ts +2 -1
- package/dist/commands/replay/replay.command.d.ts +3 -2
- package/dist/commands/replay/replay.command.js +46 -22
- package/dist/commands/replay/utils/{compute-and-save-diff.d.ts → compute-diff.d.ts} +3 -2
- package/dist/commands/replay/utils/{compute-and-save-diff.js → compute-diff.js} +5 -21
- package/dist/commands/run-all-tests/run-all-tests.command.d.ts +5 -0
- package/dist/commands/run-all-tests/run-all-tests.command.js +7 -1
- package/dist/commands/screenshot-diff/screenshot-diff.command.d.ts +5 -2
- package/dist/commands/screenshot-diff/screenshot-diff.command.js +23 -18
- package/dist/config/config.types.d.ts +6 -1
- package/dist/deflake-tests/deflake-tests.handler.d.ts +1 -0
- package/dist/deflake-tests/deflake-tests.handler.js +2 -2
- package/dist/local-data/local-data.utils.d.ts +19 -0
- package/dist/local-data/local-data.utils.js +95 -1
- package/dist/local-data/replays.d.ts +2 -1
- package/dist/local-data/replays.js +30 -32
- package/dist/local-data/sessions.d.ts +2 -1
- package/dist/local-data/sessions.js +12 -27
- package/dist/main.js +1 -7
- package/dist/parallel-tests/__tests__/merge-test-results.spec.d.ts +1 -0
- package/dist/parallel-tests/__tests__/merge-test-results.spec.js +179 -0
- package/dist/parallel-tests/merge-test-results.d.ts +10 -0
- package/dist/parallel-tests/merge-test-results.js +98 -0
- package/dist/parallel-tests/parallel-tests.handler.d.ts +1 -0
- package/dist/parallel-tests/parallel-tests.handler.js +113 -24
- package/dist/parallel-tests/run-all-tests.d.ts +7 -1
- package/dist/parallel-tests/run-all-tests.js +35 -52
- package/dist/parallel-tests/run-all-tests.types.d.ts +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/sentry.utils.d.ts +0 -1
- package/dist/utils/sentry.utils.js +19 -14
- package/package.json +26 -6
|
@@ -11,13 +11,28 @@ const common_1 = require("@alwaysmeticulous/common");
|
|
|
11
11
|
const loglevel_1 = __importDefault(require("loglevel"));
|
|
12
12
|
const config_utils_1 = require("../utils/config.utils");
|
|
13
13
|
const run_all_tests_utils_1 = require("../utils/run-all-tests.utils");
|
|
14
|
+
const merge_test_results_1 = require("./merge-test-results");
|
|
14
15
|
/** Handler for running Meticulous tests in parallel using child processes */
|
|
15
|
-
const runAllTestsInParallel = async ({ config, testRun, testsToRun
|
|
16
|
+
const runAllTestsInParallel = async ({ config, testRun, testsToRun, apiToken, commitSha, appUrl, useAssetsSnapshottedInBaseSimulation, executionOptions, screenshottingOptions, parallelTasks, deflake, maxRetriesOnFailure, replayEventsDependencies, onTestFinished, }) => {
|
|
16
17
|
const logger = loglevel_1.default.getLogger(common_1.METICULOUS_LOGGER_NAME);
|
|
17
|
-
|
|
18
|
+
let nextId = 0;
|
|
19
|
+
let queue = testsToRun.map((test) => ({
|
|
20
|
+
...test,
|
|
21
|
+
id: ++nextId,
|
|
22
|
+
}));
|
|
23
|
+
/**
|
|
24
|
+
* The current results, which may still be being updated if we're re-running a test
|
|
25
|
+
* to check for flakes.
|
|
26
|
+
*/
|
|
27
|
+
const resultsByTestId = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* Results that have been fully checked for flakes. At most one per test case.
|
|
30
|
+
*/
|
|
31
|
+
const finalResults = [];
|
|
18
32
|
const progress = {
|
|
19
33
|
runningTestCases: queue.length,
|
|
20
34
|
failedTestCases: 0,
|
|
35
|
+
flakedTestCases: 0,
|
|
21
36
|
passedTestCases: 0,
|
|
22
37
|
};
|
|
23
38
|
const allTasksDone = (0, common_1.defer)();
|
|
@@ -26,7 +41,8 @@ const runAllTestsInParallel = async ({ config, testRun, testsToRun: queue, apiTo
|
|
|
26
41
|
logger.debug(`Running with ${maxTasks} maximum tasks in parallel`);
|
|
27
42
|
const taskHandler = (0, path_1.join)(__dirname, "task.handler.js");
|
|
28
43
|
// Starts running a test case in a child process
|
|
29
|
-
const startTask = (
|
|
44
|
+
const startTask = (rerunnableTestCase) => {
|
|
45
|
+
const { id, ...testCase } = rerunnableTestCase;
|
|
30
46
|
const deferredResult = (0, common_1.defer)();
|
|
31
47
|
const child = (0, child_process_1.fork)(taskHandler, [], { stdio: "inherit" });
|
|
32
48
|
const messageHandler = (message) => {
|
|
@@ -53,6 +69,7 @@ const runAllTestsInParallel = async ({ config, testRun, testsToRun: queue, apiTo
|
|
|
53
69
|
});
|
|
54
70
|
child.on("message", messageHandler);
|
|
55
71
|
// Send test case and arguments to child process
|
|
72
|
+
const isRetry = resultsByTestId.has(id);
|
|
56
73
|
const initMessage = {
|
|
57
74
|
kind: "init",
|
|
58
75
|
data: {
|
|
@@ -73,35 +90,62 @@ const runAllTestsInParallel = async ({ config, testRun, testsToRun: queue, apiTo
|
|
|
73
90
|
generatedBy: { type: "testRun", runId: testRun.id },
|
|
74
91
|
testRunId: testRun.id,
|
|
75
92
|
replayEventsDependencies,
|
|
93
|
+
suppressScreenshotDiffLogging: isRetry,
|
|
76
94
|
},
|
|
77
95
|
},
|
|
78
96
|
};
|
|
79
97
|
child.send(initMessage);
|
|
80
98
|
// Handle task completion
|
|
81
99
|
deferredResult.promise
|
|
82
|
-
.catch(() =>
|
|
83
|
-
// If it threw an error then it's something fatal, rather than just a failed diff
|
|
84
|
-
// (it resolves successfully on a failed diff)
|
|
85
|
-
const result = {
|
|
86
|
-
...testCase,
|
|
87
|
-
headReplayId: "",
|
|
88
|
-
result: "fail",
|
|
89
|
-
screenshotDiffResults: [],
|
|
90
|
-
};
|
|
91
|
-
return result;
|
|
92
|
-
})
|
|
100
|
+
.catch(() => null)
|
|
93
101
|
.then(async (result) => {
|
|
102
|
+
var _a;
|
|
103
|
+
const resultsForTestCase = resultsByTestId.get(id);
|
|
104
|
+
if (resultsForTestCase != null && result != null) {
|
|
105
|
+
logRetrySummary(testName({ id, ...testCase }), result);
|
|
106
|
+
}
|
|
107
|
+
if (resultsForTestCase != null &&
|
|
108
|
+
resultsForTestCase.currentResult.result === "flake") {
|
|
109
|
+
// This test has already been declared as flakey, and we can ignore this result. This result
|
|
110
|
+
// was from an already executing test that we weren't able to cancel.
|
|
111
|
+
process.nextTick(checkNextTask);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
94
114
|
--inProgress;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
115
|
+
if ((result === null || result === void 0 ? void 0 : result.result) === "fail" && resultsForTestCase == null) {
|
|
116
|
+
queue.push(...Array.from(new Array(maxRetriesOnFailure)).map(() => ({
|
|
117
|
+
...testCase,
|
|
118
|
+
id,
|
|
119
|
+
baseReplayId: result.headReplayId,
|
|
120
|
+
})));
|
|
121
|
+
}
|
|
122
|
+
const mergedResult = getNewMergedResult(testCase, (_a = resultsForTestCase === null || resultsForTestCase === void 0 ? void 0 : resultsForTestCase.currentResult) !== null && _a !== void 0 ? _a : null, result);
|
|
123
|
+
const numberOfRetriesExecuted = resultsForTestCase == null
|
|
124
|
+
? 0
|
|
125
|
+
: resultsForTestCase.numberOfRetriesExecuted + 1;
|
|
126
|
+
// Our work is done for this test case if the first result was a pass,
|
|
127
|
+
// we've performed all the retries, or one of the retries already proved
|
|
128
|
+
// the result as flakey
|
|
129
|
+
const isFinalResult = mergedResult.result !== "fail" ||
|
|
130
|
+
numberOfRetriesExecuted === maxRetriesOnFailure;
|
|
131
|
+
resultsByTestId.set(id, {
|
|
132
|
+
currentResult: mergedResult,
|
|
133
|
+
numberOfRetriesExecuted,
|
|
104
134
|
});
|
|
135
|
+
if (isFinalResult) {
|
|
136
|
+
// Cancel any replays that are still scheduled
|
|
137
|
+
queue = queue.filter(({ id }) => id !== id);
|
|
138
|
+
finalResults.push(mergedResult);
|
|
139
|
+
--progress.runningTestCases;
|
|
140
|
+
progress.failedTestCases += mergedResult.result === "fail" ? 1 : 0;
|
|
141
|
+
progress.flakedTestCases += mergedResult.result === "flake" ? 1 : 0;
|
|
142
|
+
progress.passedTestCases += mergedResult.result === "pass" ? 1 : 0;
|
|
143
|
+
await (onTestFinished === null || onTestFinished === void 0 ? void 0 : onTestFinished(progress, finalResults).then(() => {
|
|
144
|
+
if (queue.length === 0 && inProgress === 0) {
|
|
145
|
+
allTasksDone.resolve();
|
|
146
|
+
}
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
105
149
|
process.nextTick(checkNextTask);
|
|
106
150
|
});
|
|
107
151
|
};
|
|
@@ -115,11 +159,56 @@ const runAllTestsInParallel = async ({ config, testRun, testsToRun: queue, apiTo
|
|
|
115
159
|
return;
|
|
116
160
|
}
|
|
117
161
|
++inProgress;
|
|
162
|
+
if (resultsByTestId.has(testCase.id)) {
|
|
163
|
+
logger.info(`Test ${testName(testCase)} failed. Retrying to check for flakes...`);
|
|
164
|
+
}
|
|
118
165
|
startTask(testCase);
|
|
119
166
|
process.nextTick(checkNextTask);
|
|
120
167
|
};
|
|
121
168
|
process.nextTick(checkNextTask);
|
|
122
169
|
await allTasksDone.promise;
|
|
123
|
-
return (0, run_all_tests_utils_1.sortResults)({
|
|
170
|
+
return (0, run_all_tests_utils_1.sortResults)({
|
|
171
|
+
results: finalResults,
|
|
172
|
+
testCases: config.testCases || [],
|
|
173
|
+
});
|
|
124
174
|
};
|
|
125
175
|
exports.runAllTestsInParallel = runAllTestsInParallel;
|
|
176
|
+
const logRetrySummary = (nameOfTest, retryResult) => {
|
|
177
|
+
const logger = loglevel_1.default.getLogger(common_1.METICULOUS_LOGGER_NAME);
|
|
178
|
+
if (retryResult.result === "pass") {
|
|
179
|
+
logger.info(`Retried taking screenshots for failed test ${nameOfTest}, but got the same results`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
const numDifferingScreenshots = retryResult.screenshotDiffResults.filter((result) => result.outcome !== "no-diff").length;
|
|
183
|
+
logger.info(`Retried taking screenshots for failed test ${nameOfTest}, and ${numDifferingScreenshots} screenshots came out different. Results for these screenshots are assumed to be flakes, and so will be ignored.`);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const testName = (testCase) => testCase.title != null ? `'${testCase.title}'` : `#${testCase.id + 1}`;
|
|
187
|
+
const getNewMergedResult = (testCase, currentMergedResult, newResult) => {
|
|
188
|
+
// If currentMergedResult is null then this is our first try, our original head replay
|
|
189
|
+
if (currentMergedResult == null) {
|
|
190
|
+
if (newResult == null) {
|
|
191
|
+
// This means our original head replay failed fatally (not just a failed diff, but failed to even run)
|
|
192
|
+
// In this case we just return it as result = fail, with no screenshot diffs
|
|
193
|
+
return {
|
|
194
|
+
...testCase,
|
|
195
|
+
headReplayId: "",
|
|
196
|
+
result: "fail",
|
|
197
|
+
screenshotDiffResults: [],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
// In this case the newResult is our first head replay, our first result,
|
|
201
|
+
// so lets initialize the mergedResult to this
|
|
202
|
+
return newResult;
|
|
203
|
+
}
|
|
204
|
+
// If currentMergedResult is not null then newResult is a retry, containing comparison screenshots to our original head replay
|
|
205
|
+
if (newResult == null) {
|
|
206
|
+
// In this case newResult is a retry of the head replay, but it failed fatally (not just a failed diff, but failed to even run)
|
|
207
|
+
// So we just ignore this retry
|
|
208
|
+
return currentMergedResult;
|
|
209
|
+
}
|
|
210
|
+
return (0, merge_test_results_1.mergeResults)({
|
|
211
|
+
currentResult: currentMergedResult,
|
|
212
|
+
comparisonToHeadReplay: newResult,
|
|
213
|
+
});
|
|
214
|
+
};
|
|
@@ -23,6 +23,12 @@ export interface Options {
|
|
|
23
23
|
*/
|
|
24
24
|
parallelTasks: number | null;
|
|
25
25
|
deflake: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* If set to a value greater than 1 then will re-run any replays that give a screenshot diff
|
|
28
|
+
* and mark them as a flake if the screenshot generated on one of the retryed replays differs from that
|
|
29
|
+
* in the first replay.
|
|
30
|
+
*/
|
|
31
|
+
maxRetriesOnFailure: number;
|
|
26
32
|
githubSummary: boolean;
|
|
27
33
|
/**
|
|
28
34
|
* If provided it will incorportate the cachedTestRunResults in any calls to store
|
|
@@ -57,4 +63,4 @@ export interface TestRun {
|
|
|
57
63
|
* Runs all the test cases in the provided file.
|
|
58
64
|
* @returns The results of the tests that were executed (note that this does not include results from any cachedTestRunResults passed in)
|
|
59
65
|
*/
|
|
60
|
-
export declare const runAllTests: ({ testsFile, apiToken, commitSha, baseCommitSha, appUrl, useAssetsSnapshottedInBaseSimulation, executionOptions, screenshottingOptions, parallelTasks, deflake, cachedTestRunResults: cachedTestRunResults_, githubSummary, environment, onTestRunCreated, onTestFinished: onTestFinished_, }: Options) => Promise<RunAllTestsResult>;
|
|
66
|
+
export declare const runAllTests: ({ testsFile, apiToken, commitSha, baseCommitSha, appUrl, useAssetsSnapshottedInBaseSimulation, executionOptions, screenshottingOptions, parallelTasks, deflake, maxRetriesOnFailure, cachedTestRunResults: cachedTestRunResults_, githubSummary, environment, onTestRunCreated, onTestFinished: onTestFinished_, }: Options) => Promise<RunAllTestsResult>;
|
|
@@ -7,12 +7,11 @@ exports.runAllTests = void 0;
|
|
|
7
7
|
const common_1 = require("@alwaysmeticulous/common");
|
|
8
8
|
const loglevel_1 = __importDefault(require("loglevel"));
|
|
9
9
|
const client_1 = require("../api/client");
|
|
10
|
+
const replay_diff_api_1 = require("../api/replay-diff.api");
|
|
10
11
|
const test_run_api_1 = require("../api/test-run.api");
|
|
11
12
|
const config_1 = require("../config/config");
|
|
12
|
-
const deflake_tests_handler_1 = require("../deflake-tests/deflake-tests.handler");
|
|
13
13
|
const replay_assets_1 = require("../local-data/replay-assets");
|
|
14
14
|
const parallel_tests_handler_1 = require("../parallel-tests/parallel-tests.handler");
|
|
15
|
-
const config_utils_1 = require("../utils/config.utils");
|
|
16
15
|
const github_summary_utils_1 = require("../utils/github-summary.utils");
|
|
17
16
|
const run_all_tests_utils_1 = require("../utils/run-all-tests.utils");
|
|
18
17
|
const test_run_environment_utils_1 = require("../utils/test-run-environment.utils");
|
|
@@ -21,10 +20,13 @@ const version_utils_1 = require("../utils/version.utils");
|
|
|
21
20
|
* Runs all the test cases in the provided file.
|
|
22
21
|
* @returns The results of the tests that were executed (note that this does not include results from any cachedTestRunResults passed in)
|
|
23
22
|
*/
|
|
24
|
-
const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appUrl, useAssetsSnapshottedInBaseSimulation, executionOptions, screenshottingOptions, parallelTasks, deflake, cachedTestRunResults: cachedTestRunResults_, githubSummary, environment, onTestRunCreated, onTestFinished: onTestFinished_, }) => {
|
|
23
|
+
const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appUrl, useAssetsSnapshottedInBaseSimulation, executionOptions, screenshottingOptions, parallelTasks, deflake, maxRetriesOnFailure, cachedTestRunResults: cachedTestRunResults_, githubSummary, environment, onTestRunCreated, onTestFinished: onTestFinished_, }) => {
|
|
25
24
|
if (appUrl != null && useAssetsSnapshottedInBaseSimulation) {
|
|
26
25
|
throw new Error("Arguments useAssetsSnapshottedInBaseSimulation and appUrl are mutually exclusive");
|
|
27
26
|
}
|
|
27
|
+
if (deflake && maxRetriesOnFailure > 1) {
|
|
28
|
+
throw new Error("Arguments deflake and maxRetriesOnFailure are mutually exclusive");
|
|
29
|
+
}
|
|
28
30
|
const logger = loglevel_1.default.getLogger(common_1.METICULOUS_LOGGER_NAME);
|
|
29
31
|
const client = (0, client_1.createClient)({ apiToken });
|
|
30
32
|
const cachedTestRunResults = cachedTestRunResults_ !== null && cachedTestRunResults_ !== void 0 ? cachedTestRunResults_ : [];
|
|
@@ -66,6 +68,7 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
66
68
|
status: "Running",
|
|
67
69
|
progress: {
|
|
68
70
|
failedTestCases: 0,
|
|
71
|
+
flakedTestCases: 0,
|
|
69
72
|
passedTestCases: cachedTestRunResults.length,
|
|
70
73
|
runningTestCases: testCases.length,
|
|
71
74
|
},
|
|
@@ -109,59 +112,37 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
109
112
|
passedTestCases: progress.passedTestCases + cachedTestRunResults.length,
|
|
110
113
|
},
|
|
111
114
|
});
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
testRun,
|
|
119
|
-
testsToRun,
|
|
120
|
-
executionOptions,
|
|
121
|
-
screenshottingOptions,
|
|
122
|
-
apiToken,
|
|
123
|
-
commitSha,
|
|
124
|
-
appUrl,
|
|
125
|
-
useAssetsSnapshottedInBaseSimulation,
|
|
126
|
-
parallelTasks,
|
|
127
|
-
deflake,
|
|
128
|
-
replayEventsDependencies,
|
|
129
|
-
onTestFinished,
|
|
130
|
-
});
|
|
131
|
-
return results;
|
|
132
|
-
}
|
|
133
|
-
const results = [];
|
|
134
|
-
const progress = {
|
|
135
|
-
runningTestCases: testsToRun.length,
|
|
136
|
-
failedTestCases: 0,
|
|
137
|
-
passedTestCases: 0,
|
|
138
|
-
};
|
|
139
|
-
for (const testCase of testsToRun) {
|
|
140
|
-
const result = await (0, deflake_tests_handler_1.deflakeReplayCommandHandler)({
|
|
141
|
-
replayTarget: (0, config_utils_1.getReplayTargetForTestCase)({
|
|
142
|
-
useAssetsSnapshottedInBaseSimulation,
|
|
143
|
-
appUrl,
|
|
144
|
-
testCase,
|
|
145
|
-
}),
|
|
146
|
-
executionOptions,
|
|
147
|
-
screenshottingOptions,
|
|
148
|
-
testCase,
|
|
149
|
-
apiToken,
|
|
150
|
-
commitSha,
|
|
151
|
-
deflake,
|
|
152
|
-
generatedBy: { type: "testRun", runId: testRun.id },
|
|
115
|
+
const newResult = resultsSoFar.at(-1);
|
|
116
|
+
if ((newResult === null || newResult === void 0 ? void 0 : newResult.baseReplayId) != null) {
|
|
117
|
+
await (0, replay_diff_api_1.createReplayDiff)({
|
|
118
|
+
client,
|
|
119
|
+
headReplayId: newResult.headReplayId,
|
|
120
|
+
baseReplayId: newResult.baseReplayId,
|
|
153
121
|
testRunId: testRun.id,
|
|
154
|
-
|
|
122
|
+
data: {
|
|
123
|
+
screenshotAssertionsOptions: screenshottingOptions,
|
|
124
|
+
screenshotDiffResults: newResult.screenshotDiffResults,
|
|
125
|
+
},
|
|
155
126
|
});
|
|
156
|
-
results.push(result);
|
|
157
|
-
progress.failedTestCases += result.result === "fail" ? 1 : 0;
|
|
158
|
-
progress.passedTestCases += result.result === "pass" ? 1 : 0;
|
|
159
|
-
--progress.runningTestCases;
|
|
160
|
-
await onTestFinished(progress, results);
|
|
161
127
|
}
|
|
162
|
-
|
|
128
|
+
await storeTestRunResults("Running", resultsSoFar);
|
|
163
129
|
};
|
|
164
|
-
const results = await
|
|
130
|
+
const results = await (0, parallel_tests_handler_1.runAllTestsInParallel)({
|
|
131
|
+
config,
|
|
132
|
+
testRun,
|
|
133
|
+
testsToRun,
|
|
134
|
+
executionOptions,
|
|
135
|
+
screenshottingOptions,
|
|
136
|
+
apiToken,
|
|
137
|
+
commitSha,
|
|
138
|
+
appUrl,
|
|
139
|
+
useAssetsSnapshottedInBaseSimulation,
|
|
140
|
+
parallelTasks,
|
|
141
|
+
deflake,
|
|
142
|
+
replayEventsDependencies,
|
|
143
|
+
onTestFinished,
|
|
144
|
+
maxRetriesOnFailure,
|
|
145
|
+
});
|
|
165
146
|
const runAllFailure = results.find(({ result }) => result === "fail");
|
|
166
147
|
const overallStatus = runAllFailure ? "Failure" : "Success";
|
|
167
148
|
await storeTestRunResults(overallStatus, results);
|
|
@@ -182,6 +163,8 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
182
163
|
id: testRun.id,
|
|
183
164
|
status: overallStatus,
|
|
184
165
|
progress: {
|
|
166
|
+
flakedTestCases: results.filter(({ result }) => result === "flake")
|
|
167
|
+
.length,
|
|
185
168
|
passedTestCases: results.filter(({ result }) => result === "pass")
|
|
186
169
|
.length,
|
|
187
170
|
failedTestCases: results.filter(({ result }) => result === "fail")
|