@alwaysmeticulous/cli 2.35.0 → 2.37.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 +0 -1
- package/dist/api/replay.api.js +1 -13
- package/dist/api/test-run.api.d.ts +8 -2
- package/dist/api/test-run.api.js +21 -3
- package/dist/api/types.d.ts +9 -0
- package/dist/commands/create-test/create-test.command.js +0 -3
- package/dist/commands/replay/replay.command.d.ts +5 -20
- package/dist/commands/replay/replay.command.js +10 -53
- package/dist/commands/replay/utils/compute-diff.d.ts +3 -6
- package/dist/commands/replay/utils/compute-diff.js +83 -18
- package/dist/commands/replay/utils/exit-early-if-skip-upload-env-var-set.d.ts +1 -1
- package/dist/commands/replay/utils/exit-early-if-skip-upload-env-var-set.js +3 -3
- package/dist/commands/run-all-tests/run-all-tests.command.d.ts +4 -5
- package/dist/commands/run-all-tests/run-all-tests.command.js +6 -9
- package/dist/commands/screenshot-diff/screenshot-diff.command.d.ts +6 -8
- package/dist/commands/screenshot-diff/screenshot-diff.command.js +50 -97
- package/dist/commands/screenshot-diff/utils/get-screenshot-filename.d.ts +2 -0
- package/dist/commands/screenshot-diff/utils/get-screenshot-filename.js +18 -0
- package/dist/commands/screenshot-diff/utils/get-screenshot-identifier.d.ts +2 -0
- package/dist/commands/screenshot-diff/utils/get-screenshot-identifier.js +24 -0
- package/dist/commands/screenshot-diff/utils/has-notable-differences.d.ts +2 -0
- package/dist/commands/screenshot-diff/utils/has-notable-differences.js +10 -0
- package/dist/config/config.js +2 -3
- package/dist/config/config.types.d.ts +1 -1
- package/dist/deflake-tests/deflake-tests.handler.d.ts +1 -0
- package/dist/deflake-tests/deflake-tests.handler.js +9 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -3
- package/dist/local-data/replays.d.ts +6 -1
- package/dist/local-data/replays.js +11 -3
- package/dist/local-data/screenshot-diffs.js +1 -1
- package/dist/local-data/serve-assets-from-simulation.js +1 -1
- package/dist/parallel-tests/__tests__/mock-test-results.js +3 -1
- package/dist/parallel-tests/__tests__/parrallel-tests.handler.spec.js +1 -1
- package/dist/parallel-tests/merge-test-results.js +18 -9
- package/dist/parallel-tests/parallel-tests.handler.js +2 -1
- package/dist/parallel-tests/run-all-tests.d.ts +2 -2
- package/dist/parallel-tests/run-all-tests.js +50 -23
- package/dist/parallel-tests/screenshot-diff-results.utils.d.ts +7 -0
- package/dist/parallel-tests/screenshot-diff-results.utils.js +20 -0
- package/dist/utils/config.utils.d.ts +1 -2
- package/dist/utils/config.utils.js +1 -10
- package/dist/utils/run-all-tests.utils.d.ts +0 -1
- package/dist/utils/run-all-tests.utils.js +3 -50
- package/package.json +6 -4
|
@@ -23,10 +23,7 @@ const execute_test_in_child_process_1 = require("./execute-test-in-child-process
|
|
|
23
23
|
* Runs all the test cases in the provided file.
|
|
24
24
|
* @returns The results of the tests that were executed (note that this does not include results from any cachedTestRunResults passed in)
|
|
25
25
|
*/
|
|
26
|
-
const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appUrl,
|
|
27
|
-
if (appUrl != null && useAssetsSnapshottedInBaseSimulation) {
|
|
28
|
-
throw new Error("Arguments useAssetsSnapshottedInBaseSimulation and appUrl are mutually exclusive");
|
|
29
|
-
}
|
|
26
|
+
const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appUrl, executionOptions, screenshottingOptions, parallelTasks, deflake, maxRetriesOnFailure, cachedTestRunResults: cachedTestRunResults_, githubSummary, environment, baseTestRunId, onTestRunCreated, onTestFinished: onTestFinished_, }) => {
|
|
30
27
|
if (deflake && maxRetriesOnFailure > 1) {
|
|
31
28
|
throw new Error("Arguments deflake and maxRetriesOnFailure are mutually exclusive");
|
|
32
29
|
}
|
|
@@ -44,8 +41,8 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
44
41
|
throw new Error("Error! No test case defined");
|
|
45
42
|
}
|
|
46
43
|
// Only run the uncached test cases
|
|
47
|
-
const testCases = allTestCases.filter(({ sessionId,
|
|
48
|
-
cached.
|
|
44
|
+
const testCases = allTestCases.filter(({ sessionId, baseTestRunId, title }) => !cachedTestRunResults.find((cached) => cached.sessionId === sessionId &&
|
|
45
|
+
cached.baseTestRunId === baseTestRunId &&
|
|
49
46
|
cached.title === title));
|
|
50
47
|
const meticulousSha = await (0, version_utils_1.getMeticulousVersion)();
|
|
51
48
|
const replayEventsDependencies = await (0, replay_assets_1.loadReplayEventsDependencies)();
|
|
@@ -62,7 +59,6 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
62
59
|
commitSha,
|
|
63
60
|
baseCommitSha,
|
|
64
61
|
appUrl,
|
|
65
|
-
useAssetsSnapshottedInBaseSimulation,
|
|
66
62
|
parallelTasks,
|
|
67
63
|
deflake,
|
|
68
64
|
githubSummary,
|
|
@@ -86,17 +82,19 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
86
82
|
logger.info("");
|
|
87
83
|
logger.info(`Test run URL: ${testRunUrl}`);
|
|
88
84
|
logger.info("");
|
|
89
|
-
const testsToRun = await (
|
|
90
|
-
|
|
85
|
+
const testsToRun = await getTestCasesWithBaseTestRunId({
|
|
86
|
+
baseCommitSha,
|
|
87
|
+
baseTestRunId: baseTestRunId !== null && baseTestRunId !== void 0 ? baseTestRunId : null,
|
|
91
88
|
client,
|
|
92
|
-
|
|
89
|
+
logger,
|
|
90
|
+
testCases,
|
|
93
91
|
});
|
|
94
92
|
const storeTestRunResults = async (status, resultsSoFar) => {
|
|
95
93
|
const resultsToSendToBE = [
|
|
96
94
|
...cachedTestRunResults,
|
|
97
95
|
...resultsSoFar.map(
|
|
98
96
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
99
|
-
({
|
|
97
|
+
({ screenshotDiffResultsByBaseReplayId, ...result }) => result),
|
|
100
98
|
];
|
|
101
99
|
try {
|
|
102
100
|
await (0, test_run_api_1.putTestRunResults)({
|
|
@@ -127,17 +125,19 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
127
125
|
const onTestFinished = async (progress, resultsSoFar) => {
|
|
128
126
|
onProgressUpdated(progress);
|
|
129
127
|
const newResult = resultsSoFar.at(-1);
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
128
|
+
if (newResult != null) {
|
|
129
|
+
for (const [baseReplayId, screenshotDiffResults] of Object.entries(newResult.screenshotDiffResultsByBaseReplayId)) {
|
|
130
|
+
await (0, replay_diff_api_1.createReplayDiff)({
|
|
131
|
+
client,
|
|
132
|
+
headReplayId: newResult.headReplayId,
|
|
133
|
+
baseReplayId: baseReplayId,
|
|
134
|
+
testRunId: testRun.id,
|
|
135
|
+
data: {
|
|
136
|
+
screenshotAssertionsOptions: screenshottingOptions,
|
|
137
|
+
screenshotDiffResults,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
141
|
}
|
|
142
142
|
await storeTestRunResults("Running", resultsSoFar);
|
|
143
143
|
};
|
|
@@ -146,6 +146,7 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
146
146
|
parallelTasks,
|
|
147
147
|
maxRetriesOnFailure,
|
|
148
148
|
executeTest: (testCase, isRetry) => {
|
|
149
|
+
var _a;
|
|
149
150
|
const initMessage = {
|
|
150
151
|
kind: "init",
|
|
151
152
|
data: {
|
|
@@ -157,7 +158,6 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
157
158
|
testCase,
|
|
158
159
|
deflake,
|
|
159
160
|
replayTarget: (0, config_utils_1.getReplayTargetForTestCase)({
|
|
160
|
-
useAssetsSnapshottedInBaseSimulation,
|
|
161
161
|
appUrl,
|
|
162
162
|
testCase,
|
|
163
163
|
}),
|
|
@@ -165,6 +165,7 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
165
165
|
screenshottingOptions,
|
|
166
166
|
generatedBy: { type: "testRun", runId: testRun.id },
|
|
167
167
|
testRunId: testRun.id,
|
|
168
|
+
baseTestRunId: (_a = testCase.baseTestRunId) !== null && _a !== void 0 ? _a : null,
|
|
168
169
|
replayEventsDependencies,
|
|
169
170
|
suppressScreenshotDiffLogging: isRetry,
|
|
170
171
|
},
|
|
@@ -212,3 +213,29 @@ const runAllTests = async ({ testsFile, apiToken, commitSha, baseCommitSha, appU
|
|
|
212
213
|
};
|
|
213
214
|
};
|
|
214
215
|
exports.runAllTests = runAllTests;
|
|
216
|
+
const getTestCasesWithBaseTestRunId = async ({ logger, client, baseCommitSha, baseTestRunId, testCases, }) => {
|
|
217
|
+
const defaultBaseTestRunId = baseCommitSha != null
|
|
218
|
+
? await (0, test_run_api_1.getLatestTestRunId)({
|
|
219
|
+
client,
|
|
220
|
+
commitSha: baseCommitSha,
|
|
221
|
+
})
|
|
222
|
+
: null;
|
|
223
|
+
const testsToRun = testCases.map((test) => {
|
|
224
|
+
// We use the baseTestRunId specified in the test case if it exists, otherwise we use
|
|
225
|
+
// use the baseTestRunId specified from the CLI args if it exists, otherwise we use the
|
|
226
|
+
// baseTestRunId for the base commit if it exists, otherwise we use null (don't compare screenshots).
|
|
227
|
+
const fallbackTestRunId = baseTestRunId !== null && baseTestRunId !== void 0 ? baseTestRunId : defaultBaseTestRunId;
|
|
228
|
+
if (test.baseTestRunId != null || fallbackTestRunId == null) {
|
|
229
|
+
return test;
|
|
230
|
+
}
|
|
231
|
+
return { ...test, baseTestRunId: fallbackTestRunId };
|
|
232
|
+
});
|
|
233
|
+
if (baseCommitSha != null) {
|
|
234
|
+
testsToRun
|
|
235
|
+
.filter((test) => test.baseTestRunId == null)
|
|
236
|
+
.forEach((test) => {
|
|
237
|
+
logger.warn(`Skipping comparisons for test "${test.title}" since no result to compare against stored for base commit ${baseCommitSha}`);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return testsToRun;
|
|
241
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ScreenshotDiffResult } from "@alwaysmeticulous/api";
|
|
2
|
+
import { DetailedTestCaseResult } from "../config/config.types";
|
|
3
|
+
export type ScreenshotDiffResultWithBaseReplayId = ScreenshotDiffResult & {
|
|
4
|
+
baseReplayId: string;
|
|
5
|
+
};
|
|
6
|
+
export declare const flattenScreenshotDiffResults: (testCaseResult: DetailedTestCaseResult) => ScreenshotDiffResultWithBaseReplayId[];
|
|
7
|
+
export declare const groupScreenshotDiffResults: (results: ScreenshotDiffResultWithBaseReplayId[]) => Record<string, ScreenshotDiffResult[]>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.groupScreenshotDiffResults = exports.flattenScreenshotDiffResults = void 0;
|
|
4
|
+
const flattenScreenshotDiffResults = (testCaseResult) => {
|
|
5
|
+
return Object.entries(testCaseResult.screenshotDiffResultsByBaseReplayId).flatMap(([baseReplayId, diffs]) => {
|
|
6
|
+
return diffs.map((diff) => ({ ...diff, baseReplayId }));
|
|
7
|
+
});
|
|
8
|
+
};
|
|
9
|
+
exports.flattenScreenshotDiffResults = flattenScreenshotDiffResults;
|
|
10
|
+
const groupScreenshotDiffResults = (results) => {
|
|
11
|
+
const groupedResults = {};
|
|
12
|
+
results.forEach(({ baseReplayId, ...result }) => {
|
|
13
|
+
var _a;
|
|
14
|
+
const resultsForBaseReplayId = (_a = groupedResults[baseReplayId]) !== null && _a !== void 0 ? _a : [];
|
|
15
|
+
resultsForBaseReplayId.push(result);
|
|
16
|
+
groupedResults[baseReplayId] = resultsForBaseReplayId;
|
|
17
|
+
});
|
|
18
|
+
return groupedResults;
|
|
19
|
+
};
|
|
20
|
+
exports.groupScreenshotDiffResults = groupScreenshotDiffResults;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { TestCase } from "@alwaysmeticulous/api";
|
|
2
2
|
import { ReplayTarget } from "@alwaysmeticulous/common/dist/types/replay.types";
|
|
3
3
|
export declare const addTestCase: (testCase: TestCase) => Promise<void>;
|
|
4
|
-
export declare const getReplayTargetForTestCase: ({
|
|
5
|
-
useAssetsSnapshottedInBaseSimulation: boolean;
|
|
4
|
+
export declare const getReplayTargetForTestCase: ({ appUrl, testCase, }: {
|
|
6
5
|
appUrl: string | null;
|
|
7
6
|
testCase: TestCase;
|
|
8
7
|
}) => ReplayTarget;
|
|
@@ -11,7 +11,7 @@ const addTestCase = async (testCase) => {
|
|
|
11
11
|
await (0, config_1.saveConfig)(newConfig);
|
|
12
12
|
};
|
|
13
13
|
exports.addTestCase = addTestCase;
|
|
14
|
-
const getReplayTargetForTestCase = ({
|
|
14
|
+
const getReplayTargetForTestCase = ({ appUrl, testCase, }) => {
|
|
15
15
|
var _a, _b, _c;
|
|
16
16
|
if (((_a = testCase.options) === null || _a === void 0 ? void 0 : _a.simulationIdForAssets) != null) {
|
|
17
17
|
return {
|
|
@@ -19,15 +19,6 @@ const getReplayTargetForTestCase = ({ useAssetsSnapshottedInBaseSimulation, appU
|
|
|
19
19
|
simulationIdForAssets: (_b = testCase.options) === null || _b === void 0 ? void 0 : _b.simulationIdForAssets,
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
-
if (useAssetsSnapshottedInBaseSimulation) {
|
|
23
|
-
if (testCase.baseReplayId == null) {
|
|
24
|
-
throw new Error(`--useAssetsSnapshottedInBaseSimulation flag set, but test case "${testCase.title}" does not have a baseReplayId.`);
|
|
25
|
-
}
|
|
26
|
-
return {
|
|
27
|
-
type: "snapshotted-assets",
|
|
28
|
-
simulationIdForAssets: testCase.baseReplayId,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
22
|
if ((_c = testCase.options) === null || _c === void 0 ? void 0 : _c.appUrl) {
|
|
32
23
|
if (appUrl) {
|
|
33
24
|
throw new Error(`Test cases "${testCase.title}" has an "appUrl" option but --appUrl is also provided.`);
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
7
|
-
const common_1 = require("@alwaysmeticulous/common");
|
|
8
|
-
const loglevel_1 = __importDefault(require("loglevel"));
|
|
9
|
-
const test_run_api_1 = require("../api/test-run.api");
|
|
3
|
+
exports.sortResults = exports.mergeTestCases = void 0;
|
|
10
4
|
const mergeTestCases = (...testSuites) => {
|
|
11
5
|
const seenSessionIds = new Set();
|
|
12
6
|
return testSuites.flatMap((testSuite) => {
|
|
@@ -22,9 +16,9 @@ exports.mergeTestCases = mergeTestCases;
|
|
|
22
16
|
const sortResults = ({ results: unsorted_, testCases }) => {
|
|
23
17
|
const unsorted = [...unsorted_];
|
|
24
18
|
const results = [];
|
|
25
|
-
testCases.forEach(({ title,
|
|
19
|
+
testCases.forEach(({ title, baseTestRunId, sessionId }) => {
|
|
26
20
|
const idx = unsorted.findIndex((result) => result.title === title &&
|
|
27
|
-
result.
|
|
21
|
+
result.baseTestRunId === baseTestRunId &&
|
|
28
22
|
result.sessionId === sessionId);
|
|
29
23
|
if (idx == -1) {
|
|
30
24
|
return;
|
|
@@ -36,44 +30,3 @@ const sortResults = ({ results: unsorted_, testCases }) => {
|
|
|
36
30
|
return results;
|
|
37
31
|
};
|
|
38
32
|
exports.sortResults = sortResults;
|
|
39
|
-
const getTestsToRun = async ({ testCases, client, baseCommitSha, }) => {
|
|
40
|
-
const testCasesMissingBaseReplayId = testCases.filter((testCase) => testCase.baseReplayId == null);
|
|
41
|
-
const testCasesWithBaseReplayId = testCases.flatMap((testCase) => testCase.baseReplayId == null ? [] : [testCase]);
|
|
42
|
-
if (testCasesMissingBaseReplayId.length === 0) {
|
|
43
|
-
return testCasesWithBaseReplayId;
|
|
44
|
-
}
|
|
45
|
-
const baseReplayIdBySessionId = await getBaseReplayIdsBySessionId({
|
|
46
|
-
client,
|
|
47
|
-
baseCommitSha,
|
|
48
|
-
});
|
|
49
|
-
return testCases.flatMap((test) => {
|
|
50
|
-
if (test.baseReplayId != null) {
|
|
51
|
-
return [test];
|
|
52
|
-
}
|
|
53
|
-
const baseReplayId = baseReplayIdBySessionId[test.sessionId];
|
|
54
|
-
if (baseReplayId == null) {
|
|
55
|
-
const logger = loglevel_1.default.getLogger(common_1.METICULOUS_LOGGER_NAME);
|
|
56
|
-
logger.warn(`Skipping comparisons for test "${test.title}" since no result to compare against stored for base commit ${baseCommitSha}`);
|
|
57
|
-
return [test];
|
|
58
|
-
}
|
|
59
|
-
return [{ ...test, baseReplayId }];
|
|
60
|
-
});
|
|
61
|
-
};
|
|
62
|
-
exports.getTestsToRun = getTestsToRun;
|
|
63
|
-
const getBaseReplayIdsBySessionId = async ({ client, baseCommitSha, }) => {
|
|
64
|
-
var _a, _b;
|
|
65
|
-
if (!baseCommitSha) {
|
|
66
|
-
return {};
|
|
67
|
-
}
|
|
68
|
-
const baseTestRun = await (0, test_run_api_1.getLatestTestRunResults)({
|
|
69
|
-
client,
|
|
70
|
-
commitSha: baseCommitSha,
|
|
71
|
-
});
|
|
72
|
-
const baseReplays = (_b = (_a = baseTestRun === null || baseTestRun === void 0 ? void 0 : baseTestRun.resultData) === null || _a === void 0 ? void 0 : _a.results) !== null && _b !== void 0 ? _b : [];
|
|
73
|
-
const baseReplayIdBySessionId = {};
|
|
74
|
-
// If there are multiple replays for a given session we take the last in the list
|
|
75
|
-
baseReplays.forEach((replay) => {
|
|
76
|
-
baseReplayIdBySessionId[replay.sessionId] = replay.headReplayId;
|
|
77
|
-
});
|
|
78
|
-
return baseReplayIdBySessionId;
|
|
79
|
-
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alwaysmeticulous/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.37.0",
|
|
4
4
|
"description": "The Meticulous CLI",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
"lint:fix": "eslint src --ext=ts,tsx,js --cache --fix",
|
|
23
23
|
"cli": "node dist/main.js",
|
|
24
24
|
"cli:dev": "ts-node src/main.ts",
|
|
25
|
+
"cli:dev-localhost": "METICULOUS_API_URL=http://localhost:3001/api/ ts-node src/main.ts",
|
|
25
26
|
"test": "jest"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"@alwaysmeticulous/common": "^2.
|
|
29
|
+
"@alwaysmeticulous/common": "^2.37.0",
|
|
29
30
|
"@sentry/node": "^7.36.0",
|
|
30
31
|
"@sentry/tracing": "^7.36.0",
|
|
31
32
|
"adm-zip": "^0.5.9",
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
"chalk": "^4.1.2",
|
|
35
36
|
"cosmiconfig": "^8.0.0",
|
|
36
37
|
"express": "^4.18.1",
|
|
38
|
+
"fast-json-stable-stringify": "^2.1.0",
|
|
37
39
|
"find-free-port": "^2.0.0",
|
|
38
40
|
"inquirer": "^8.2.4",
|
|
39
41
|
"luxon": "^3.2.1",
|
|
@@ -43,7 +45,7 @@
|
|
|
43
45
|
"yargs": "^17.5.1"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
|
-
"@alwaysmeticulous/api": "^2.
|
|
48
|
+
"@alwaysmeticulous/api": "^2.37.0",
|
|
47
49
|
"@types/express": "^4.17.14",
|
|
48
50
|
"@types/proper-lockfile": "^4.1.2"
|
|
49
51
|
},
|
|
@@ -93,5 +95,5 @@
|
|
|
93
95
|
"coverageDirectory": "../coverage",
|
|
94
96
|
"testEnvironment": "node"
|
|
95
97
|
},
|
|
96
|
-
"gitHead": "
|
|
98
|
+
"gitHead": "4ad32ef54767510ad29b08254603b774e4001f4b"
|
|
97
99
|
}
|