@datadog/datadog-ci 1.1.0 → 1.2.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.
@@ -15,6 +15,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.UploadCommand = void 0;
16
16
  const chalk_1 = __importDefault(require("chalk"));
17
17
  const clipanion_1 = require("clipanion");
18
+ const fs_1 = require("fs");
19
+ const glob_1 = __importDefault(require("glob"));
18
20
  const path_1 = __importDefault(require("path"));
19
21
  const tiny_async_pool_1 = __importDefault(require("tiny-async-pool"));
20
22
  const apikey_1 = require("../../helpers/apikey");
@@ -22,44 +24,155 @@ const errors_1 = require("../../helpers/errors");
22
24
  const metrics_1 = require("../../helpers/metrics");
23
25
  const upload_1 = require("../../helpers/upload");
24
26
  const utils_1 = require("../../helpers/utils");
27
+ const interfaces_1 = require("./interfaces");
25
28
  const renderer_1 = require("./renderer");
26
29
  const utils_2 = require("./utils");
27
30
  class UploadCommand extends clipanion_1.Command {
28
31
  constructor() {
29
- super(...arguments);
32
+ super();
30
33
  this.config = {
31
34
  apiKey: process.env.DATADOG_API_KEY,
32
35
  datadogSite: process.env.DATADOG_SITE || 'datadoghq.com',
33
36
  };
34
37
  this.dryRun = false;
35
38
  this.maxConcurrency = 20;
39
+ this.compressDSYMsToDirectory = (dsyms, directoryPath) => __awaiter(this, void 0, void 0, function* () {
40
+ yield fs_1.promises.mkdir(directoryPath, { recursive: true });
41
+ return Promise.all(dsyms.map((dsym) => __awaiter(this, void 0, void 0, function* () {
42
+ const archivePath = utils_1.buildPath(directoryPath, `${dsym.slices[0].uuid}.zip`);
43
+ yield utils_2.zipDirectoryToArchive(dsym.bundlePath, archivePath);
44
+ return new interfaces_1.CompressedDsym(archivePath, dsym);
45
+ })));
46
+ });
47
+ this.findDSYMsInDirectory = (directoryPath) => __awaiter(this, void 0, void 0, function* () {
48
+ const dsyms = [];
49
+ for (const dSYMPath of glob_1.default.sync(utils_1.buildPath(directoryPath, '**/*.dSYM'))) {
50
+ try {
51
+ const stdout = (yield utils_2.executeDwarfdump(dSYMPath)).stdout;
52
+ const archSlices = this.parseDwarfdumpOutput(stdout);
53
+ dsyms.push({ bundlePath: dSYMPath, slices: archSlices });
54
+ }
55
+ catch (_a) {
56
+ this.context.stdout.write(renderer_1.renderInvalidDsymWarning(dSYMPath));
57
+ }
58
+ }
59
+ return Promise.all(dsyms);
60
+ });
61
+ /**
62
+ * Parses the output of `dwarfdump --uuid` command (ref.: https://www.unix.com/man-page/osx/1/dwarfdump/).
63
+ * It returns one or many arch slices read from the output.
64
+ *
65
+ * Example `dwarfdump --uuid` output:
66
+ * ```
67
+ * $ dwarfdump --uuid DDTest.framework.dSYM
68
+ * UUID: C8469F85-B060-3085-B69D-E46C645560EA (armv7) DDTest.framework.dSYM/Contents/Resources/DWARF/DDTest
69
+ * UUID: 06EE3D68-D605-3E92-B92D-2F48C02A505E (arm64) DDTest.framework.dSYM/Contents/Resources/DWARF/DDTest
70
+ * ```
71
+ */
72
+ this.parseDwarfdumpOutput = (output) => {
73
+ const lineRegexp = /UUID: ([0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}) \(([a-z0-9_]+)\) (.+)/;
74
+ return output
75
+ .split('\n')
76
+ .map((line) => {
77
+ const match = line.match(lineRegexp);
78
+ return match ? [{ arch: match[2], objectPath: match[3], uuid: match[1] }] : [];
79
+ })
80
+ .reduce((acc, nextSlice) => acc.concat(nextSlice), []);
81
+ };
82
+ /**
83
+ * It takes fat dSYM as input and returns multiple dSYMs by extracting **each arch**
84
+ * to separate dSYM file. New files are saved to `intermediatePath` and named by their object uuid (`<uuid>.dSYM`).
85
+ *
86
+ * For example, given `<source path>/Foo.dSYM/Contents/Resources/DWARF/Foo` dSYM with two arch slices: `arm64` (uuid1)
87
+ * and `x86_64` (uuid2), it will:
88
+ * - create `<intermediate path>/<uuid1>.dSYM/Contents/Resources/DWARF/Foo` for `arm64`,
89
+ * - create `<intermediate path>/<uuid2>.dSYM/Contents/Resources/DWARF/Foo` for `x86_64`.
90
+ */
91
+ this.thinDSYM = (dsym, intermediatePath) => __awaiter(this, void 0, void 0, function* () {
92
+ const slimmedDSYMs = [];
93
+ for (const slice of dsym.slices) {
94
+ try {
95
+ const newDSYMBundleName = `${slice.uuid}.dSYM`;
96
+ const newDSYMBundlePath = utils_1.buildPath(intermediatePath, newDSYMBundleName);
97
+ const newObjectPath = utils_1.buildPath(newDSYMBundlePath, path_1.default.relative(dsym.bundlePath, slice.objectPath));
98
+ // Extract arch slice:
99
+ yield fs_1.promises.mkdir(path_1.default.dirname(newObjectPath), { recursive: true });
100
+ yield utils_2.executeLipo(slice.objectPath, slice.arch, newObjectPath);
101
+ // The original dSYM bundle can also include `Info.plist` file, so copy it to the `<uuid>.dSYM` as well.
102
+ // Ref.: https://opensource.apple.com/source/lldb/lldb-179.1/www/symbols.html
103
+ const infoPlistPath = glob_1.default.sync(utils_1.buildPath(dsym.bundlePath, '**/Info.plist'))[0];
104
+ if (infoPlistPath) {
105
+ const newInfoPlistPath = utils_1.buildPath(newDSYMBundlePath, path_1.default.relative(dsym.bundlePath, infoPlistPath));
106
+ yield fs_1.promises.mkdir(path_1.default.dirname(newInfoPlistPath), { recursive: true });
107
+ yield fs_1.promises.copyFile(infoPlistPath, newInfoPlistPath);
108
+ }
109
+ slimmedDSYMs.push({
110
+ bundlePath: newDSYMBundlePath,
111
+ slices: [{ arch: slice.arch, uuid: slice.uuid, objectPath: newObjectPath }],
112
+ });
113
+ }
114
+ catch (_b) {
115
+ this.context.stdout.write(renderer_1.renderDSYMSlimmingFailure(dsym, slice));
116
+ }
117
+ }
118
+ return Promise.all(slimmedDSYMs);
119
+ });
120
+ /**
121
+ * It takes `N` dSYMs and returns `N` or more dSYMs. If a dSYM includes more than one arch slice,
122
+ * it will be thinned by extracting each arch to a new dSYM in `intermediatePath`.
123
+ */
124
+ this.thinDSYMs = (dsyms, intermediatePath) => __awaiter(this, void 0, void 0, function* () {
125
+ yield fs_1.promises.mkdir(intermediatePath, { recursive: true });
126
+ let slimDSYMs = [];
127
+ for (const dsym of dsyms) {
128
+ if (dsym.slices.length > 1) {
129
+ slimDSYMs = slimDSYMs.concat(yield this.thinDSYM(dsym, intermediatePath));
130
+ }
131
+ else {
132
+ slimDSYMs.push(dsym);
133
+ }
134
+ }
135
+ return Promise.all(slimDSYMs);
136
+ });
137
+ this.cliVersion = require('../../../package.json').version;
36
138
  }
37
139
  execute() {
38
140
  return __awaiter(this, void 0, void 0, function* () {
141
+ // Normalizing the basePath to resolve .. and .
39
142
  this.basePath = path_1.default.posix.normalize(this.basePath);
40
- const cliVersion = require('../../../package.json').version;
143
+ this.context.stdout.write(renderer_1.renderCommandInfo(this.basePath, this.maxConcurrency, this.dryRun));
41
144
  const metricsLogger = metrics_1.getMetricsLogger({
42
145
  datadogSite: process.env.DATADOG_SITE,
43
- defaultTags: [`cli_version:${cliVersion}`],
146
+ defaultTags: [`cli_version:${this.cliVersion}`],
44
147
  prefix: 'datadog.ci.dsyms.',
45
148
  });
46
- this.context.stdout.write(renderer_1.renderCommandInfo(this.basePath, this.maxConcurrency, this.dryRun));
47
- const initialTime = Date.now();
48
- let searchPath = this.basePath;
49
- if (yield utils_2.isZipFile(this.basePath)) {
50
- searchPath = yield utils_2.unzipToTmpDir(this.basePath);
51
- }
52
149
  const apiKeyValidator = apikey_1.newApiKeyValidator({
53
150
  apiKey: this.config.apiKey,
54
151
  datadogSite: this.config.datadogSite,
55
152
  metricsLogger: metricsLogger.logger,
56
153
  });
57
- const payloads = yield utils_2.getMatchingDSYMFiles(searchPath, this.context);
58
- const validPayloads = payloads.filter((payload) => payload !== undefined);
154
+ const initialTime = Date.now();
155
+ const tmpDirectory = yield utils_2.createUniqueTmpDirectory();
156
+ const intermediateDirectory = utils_1.buildPath(tmpDirectory, 'datadog-ci', 'dsyms', 'intermediate');
157
+ const uploadDirectory = utils_1.buildPath(tmpDirectory, 'datadog-ci', 'dsyms', 'upload');
158
+ this.context.stdout.write(renderer_1.renderCommandDetail(intermediateDirectory, uploadDirectory));
159
+ // The CLI input path can be a folder or `.zip` archive with `*.dSYM` files.
160
+ // In case of `.zip`, extract it to temporary location, so it can be handled the same way as folder.
161
+ let dSYMsSearchDirectory = this.basePath;
162
+ if (yield utils_2.isZipFile(this.basePath)) {
163
+ yield utils_2.unzipArchiveToDirectory(this.basePath, tmpDirectory);
164
+ dSYMsSearchDirectory = tmpDirectory;
165
+ }
166
+ const dsyms = yield this.findDSYMsInDirectory(dSYMsSearchDirectory);
167
+ // Reduce dSYMs size by extracting arch slices from fat dSYMs to separate single-arch dSYMs in intermediate location.
168
+ // This is to avoid exceeding intake limit whenever possible.
169
+ const slimDSYMs = yield this.thinDSYMs(dsyms, intermediateDirectory);
170
+ // Compress each dSYM into single `.zip` archive.
171
+ const compressedDSYMs = yield this.compressDSYMsToDirectory(slimDSYMs, uploadDirectory);
59
172
  const requestBuilder = this.getRequestBuilder();
60
173
  const uploadDSYM = this.uploadDSYM(requestBuilder, metricsLogger, apiKeyValidator);
61
174
  try {
62
- const results = yield tiny_async_pool_1.default(this.maxConcurrency, validPayloads, uploadDSYM);
175
+ const results = yield tiny_async_pool_1.default(this.maxConcurrency, compressedDSYMs, uploadDSYM);
63
176
  const totalTime = (Date.now() - initialTime) / 1000;
64
177
  this.context.stdout.write(renderer_1.renderSuccessfulCommand(results, totalTime, this.dryRun));
65
178
  metricsLogger.logger.gauge('duration', totalTime);
@@ -74,6 +187,7 @@ class UploadCommand extends clipanion_1.Command {
74
187
  throw error;
75
188
  }
76
189
  finally {
190
+ yield utils_2.deleteDirectory(tmpDirectory);
77
191
  try {
78
192
  yield metricsLogger.flush();
79
193
  }
@@ -94,7 +208,7 @@ class UploadCommand extends clipanion_1.Command {
94
208
  }
95
209
  uploadDSYM(requestBuilder, metricsLogger, apiKeyValidator) {
96
210
  return (dSYM) => __awaiter(this, void 0, void 0, function* () {
97
- const payload = yield dSYM.asMultipartPayload();
211
+ const payload = dSYM.asMultipartPayload();
98
212
  if (this.dryRun) {
99
213
  this.context.stdout.write(`[DRYRUN] ${renderer_1.renderUpload(dSYM)}`);
100
214
  return upload_1.UploadStatus.Success;
@@ -1,9 +1,15 @@
1
- import { BaseContext } from 'clipanion';
2
- import { Dsym } from './interfaces';
3
1
  export declare const isZipFile: (filepath: string) => Promise<boolean>;
4
- export declare const getMatchingDSYMFiles: (absoluteFolderPath: string, context: BaseContext) => Promise<(Dsym | undefined)[]>;
5
- export declare const dwarfdumpUUID: (filePath: string) => Promise<string[]>;
6
- export declare const zipToTmpDir: (sourcePath: string, targetFilename: string) => Promise<string>;
7
- export declare const unzipToTmpDir: (sourcePath: string) => Promise<string>;
2
+ export declare const createUniqueTmpDirectory: () => Promise<string>;
3
+ export declare const deleteDirectory: (directoryPath: string) => Promise<void>;
4
+ export declare const zipDirectoryToArchive: (directoryPath: string, archivePath: string) => Promise<void>;
5
+ export declare const unzipArchiveToDirectory: (archivePath: string, directoryPath: string) => Promise<void>;
6
+ export declare const executeDwarfdump: (dSYMPath: string) => Promise<{
7
+ stderr: string;
8
+ stdout: string;
9
+ }>;
10
+ export declare const executeLipo: (objectPath: string, arch: string, newObjectPath: string) => Promise<{
11
+ stderr: string;
12
+ stdout: string;
13
+ }>;
8
14
  export declare const getBaseIntakeUrl: () => string;
9
15
  export declare const pluralize: (nb: number, singular: string, plural: string) => string;
@@ -12,18 +12,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.pluralize = exports.getBaseIntakeUrl = exports.unzipToTmpDir = exports.zipToTmpDir = exports.dwarfdumpUUID = exports.getMatchingDSYMFiles = exports.isZipFile = void 0;
15
+ exports.pluralize = exports.getBaseIntakeUrl = exports.executeLipo = exports.executeDwarfdump = exports.unzipArchiveToDirectory = exports.zipDirectoryToArchive = exports.deleteDirectory = exports.createUniqueTmpDirectory = exports.isZipFile = void 0;
16
16
  const child_process_1 = require("child_process");
17
17
  const fs_1 = require("fs");
18
- const glob_1 = __importDefault(require("glob"));
19
18
  const os_1 = require("os");
20
19
  const path_1 = __importDefault(require("path"));
20
+ const rimraf_1 = __importDefault(require("rimraf"));
21
21
  const util_1 = require("util");
22
- const interfaces_1 = require("./interfaces");
23
22
  const utils_1 = require("../../helpers/utils");
24
- const renderer_1 = require("./renderer");
25
- const UUID_REGEX = '[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}';
26
- const globAsync = util_1.promisify(glob_1.default);
27
23
  const isZipFile = (filepath) => __awaiter(void 0, void 0, void 0, function* () {
28
24
  try {
29
25
  const stats = yield fs_1.promises.stat(filepath);
@@ -35,54 +31,30 @@ const isZipFile = (filepath) => __awaiter(void 0, void 0, void 0, function* () {
35
31
  }
36
32
  });
37
33
  exports.isZipFile = isZipFile;
38
- const getMatchingDSYMFiles = (absoluteFolderPath, context) => __awaiter(void 0, void 0, void 0, function* () {
39
- const dSYMFiles = yield globAsync(utils_1.buildPath(absoluteFolderPath, '**/*.dSYM'), {});
40
- const allDsyms = dSYMFiles.map((dSYMPath) => __awaiter(void 0, void 0, void 0, function* () {
41
- try {
42
- const uuids = yield exports.dwarfdumpUUID(dSYMPath);
43
- return new interfaces_1.Dsym(dSYMPath, uuids);
44
- }
45
- catch (_a) {
46
- context.stdout.write(renderer_1.renderInvalidDsymWarning(dSYMPath));
47
- return undefined;
48
- }
49
- }));
50
- return Promise.all(allDsyms);
34
+ const createUniqueTmpDirectory = () => __awaiter(void 0, void 0, void 0, function* () {
35
+ const uniqueValue = Math.random() * Number.MAX_SAFE_INTEGER;
36
+ const directoryPath = utils_1.buildPath(os_1.tmpdir(), uniqueValue.toString());
37
+ yield fs_1.promises.mkdir(directoryPath, { recursive: true });
38
+ return directoryPath;
51
39
  });
52
- exports.getMatchingDSYMFiles = getMatchingDSYMFiles;
53
- const dwarfdumpUUID = (filePath) => __awaiter(void 0, void 0, void 0, function* () {
54
- const output = yield execute(`dwarfdump --uuid '${filePath}'`);
55
- const uuids = [];
56
- output.stdout.split('\n').forEach((line) => {
57
- const regexMatches = line.match(UUID_REGEX);
58
- if (regexMatches && regexMatches.length > 0) {
59
- uuids.push(regexMatches[0]);
60
- }
61
- });
62
- return uuids;
63
- });
64
- exports.dwarfdumpUUID = dwarfdumpUUID;
65
- const tmpFolder = utils_1.buildPath(os_1.tmpdir(), 'datadog-ci', 'dsyms');
66
- const zipToTmpDir = (sourcePath, targetFilename) => __awaiter(void 0, void 0, void 0, function* () {
67
- yield fs_1.promises.mkdir(tmpFolder, { recursive: true });
68
- const targetPath = utils_1.buildPath(tmpFolder, targetFilename);
69
- const sourceDir = path_1.default.dirname(sourcePath);
70
- const sourceFile = path_1.default.basename(sourcePath);
71
- // `zip -r foo.zip f1/f2/f3/foo.dSYM`
72
- // this keeps f1/f2/f3 folders in foo.zip, we don't want this
73
- // `cwd: sourceDir` is passed to avoid that
74
- yield execute(`zip -r '${targetPath}' '${sourceFile}'`, sourceDir);
75
- return targetPath;
40
+ exports.createUniqueTmpDirectory = createUniqueTmpDirectory;
41
+ const deleteDirectory = (directoryPath) => __awaiter(void 0, void 0, void 0, function* () { return new Promise((resolve, reject) => rimraf_1.default(directoryPath, () => resolve())); });
42
+ exports.deleteDirectory = deleteDirectory;
43
+ const zipDirectoryToArchive = (directoryPath, archivePath) => __awaiter(void 0, void 0, void 0, function* () {
44
+ const cwd = path_1.default.dirname(directoryPath);
45
+ const directoryName = path_1.default.basename(directoryPath);
46
+ yield execute(`zip -r '${archivePath}' '${directoryName}'`, cwd);
76
47
  });
77
- exports.zipToTmpDir = zipToTmpDir;
78
- const unzipToTmpDir = (sourcePath) => __awaiter(void 0, void 0, void 0, function* () {
79
- const targetPath = utils_1.buildPath(tmpFolder, path_1.default.basename(sourcePath, '.zip'), Date.now().toString());
80
- const dirPath = path_1.default.dirname(targetPath);
81
- yield fs_1.promises.mkdir(dirPath, { recursive: true });
82
- yield execute(`unzip -o '${sourcePath}' -d '${targetPath}'`);
83
- return targetPath;
48
+ exports.zipDirectoryToArchive = zipDirectoryToArchive;
49
+ const unzipArchiveToDirectory = (archivePath, directoryPath) => __awaiter(void 0, void 0, void 0, function* () {
50
+ yield fs_1.promises.mkdir(directoryPath, { recursive: true });
51
+ yield execute(`unzip -o '${archivePath}' -d '${directoryPath}'`);
84
52
  });
85
- exports.unzipToTmpDir = unzipToTmpDir;
53
+ exports.unzipArchiveToDirectory = unzipArchiveToDirectory;
54
+ const executeDwarfdump = (dSYMPath) => __awaiter(void 0, void 0, void 0, function* () { return execute(`dwarfdump --uuid '${dSYMPath}'`); });
55
+ exports.executeDwarfdump = executeDwarfdump;
56
+ const executeLipo = (objectPath, arch, newObjectPath) => __awaiter(void 0, void 0, void 0, function* () { return execute(`lipo '${objectPath}' -thin ${arch} -output '${newObjectPath}'`); });
57
+ exports.executeLipo = executeLipo;
86
58
  const execProc = util_1.promisify(child_process_1.exec);
87
59
  const execute = (cmd, cwd) => execProc(cmd, {
88
60
  cwd,
@@ -129,30 +129,36 @@ describe('run-test', () => {
129
129
  expect(startTunnelSpy).toHaveBeenCalledTimes(1);
130
130
  expect(stopTunnelSpy).toHaveBeenCalledTimes(1);
131
131
  }));
132
- test('getTestsList throws', () => __awaiter(void 0, void 0, void 0, function* () {
133
- const serverError = new Error('Server Error');
134
- serverError.response = { data: { errors: ['Bad Gateway'] }, status: 502 };
135
- serverError.config = { baseURL: 'baseURL', url: 'url' };
136
- const apiHelper = {
137
- searchTests: jest.fn(() => {
138
- throw serverError;
139
- }),
140
- };
141
- jest.spyOn(runTests, 'getApiHelper').mockImplementation(() => apiHelper);
142
- yield expect(runTests.executeTests(fixtures_1.mockReporter, Object.assign(Object.assign({}, fixtures_1.ciConfig), { testSearchQuery: 'a-search-query', tunnel: true }))).rejects.toMatchError(new errors_1.CriticalError('UNAVAILABLE_TEST_CONFIG'));
143
- }));
144
- test('getTestsToTrigger throws', () => __awaiter(void 0, void 0, void 0, function* () {
145
- const serverError = new Error('Server Error');
146
- serverError.response = { data: { errors: ['Bad Gateway'] }, status: 502 };
147
- serverError.config = { baseURL: 'baseURL', url: 'url' };
148
- const apiHelper = {
149
- getTest: jest.fn(() => {
150
- throw serverError;
151
- }),
152
- };
153
- jest.spyOn(runTests, 'getApiHelper').mockImplementation(() => apiHelper);
154
- yield expect(runTests.executeTests(fixtures_1.mockReporter, Object.assign(Object.assign({}, fixtures_1.ciConfig), { publicIds: ['public-id-1'], tunnel: true }))).rejects.toMatchError(new errors_1.CriticalError('UNAVAILABLE_TEST_CONFIG'));
155
- }));
132
+ const cases = [
133
+ [403, 'AUTHORIZATION_ERROR'],
134
+ [502, 'UNAVAILABLE_TEST_CONFIG'],
135
+ ];
136
+ describe.each(cases)('%s triggers %s', (status, error) => {
137
+ test(`getTestsList throws - ${status}`, () => __awaiter(void 0, void 0, void 0, function* () {
138
+ const serverError = new Error('Server Error');
139
+ serverError.response = { data: { errors: ['Error'] }, status };
140
+ serverError.config = { baseURL: 'baseURL', url: 'url' };
141
+ const apiHelper = {
142
+ searchTests: jest.fn(() => {
143
+ throw serverError;
144
+ }),
145
+ };
146
+ jest.spyOn(runTests, 'getApiHelper').mockImplementation(() => apiHelper);
147
+ yield expect(runTests.executeTests(fixtures_1.mockReporter, Object.assign(Object.assign({}, fixtures_1.ciConfig), { testSearchQuery: 'a-search-query', tunnel: true }))).rejects.toMatchError(new errors_1.CriticalError(error));
148
+ }));
149
+ test(`getTestsToTrigger throws - ${status}`, () => __awaiter(void 0, void 0, void 0, function* () {
150
+ const serverError = new Error('Server Error');
151
+ serverError.response = { data: { errors: ['Bad Gateway'] }, status };
152
+ serverError.config = { baseURL: 'baseURL', url: 'url' };
153
+ const apiHelper = {
154
+ getTest: jest.fn(() => {
155
+ throw serverError;
156
+ }),
157
+ };
158
+ jest.spyOn(runTests, 'getApiHelper').mockImplementation(() => apiHelper);
159
+ yield expect(runTests.executeTests(fixtures_1.mockReporter, Object.assign(Object.assign({}, fixtures_1.ciConfig), { publicIds: ['public-id-1'], tunnel: true }))).rejects.toMatchError(new errors_1.CriticalError(error));
160
+ }));
161
+ });
156
162
  test('getPresignedURL throws', () => __awaiter(void 0, void 0, void 0, function* () {
157
163
  jest.spyOn(utils, 'getTestsToTrigger').mockReturnValue(Promise.resolve({
158
164
  overriddenTestsToTrigger: [],
@@ -87,27 +87,36 @@ describe('utils', () => {
87
87
  test('runTests sends batch metadata', () => __awaiter(void 0, void 0, void 0, function* () {
88
88
  jest.spyOn(ciHelpers, 'getCIMetadata').mockImplementation(() => undefined);
89
89
  const payloadMetadataSpy = jest.fn();
90
- const axiosMock = jest.spyOn(axios_1.default, 'create');
91
- axiosMock.mockImplementation((() => (e) => {
92
- payloadMetadataSpy(e.data.metadata);
93
- if (e.url === '/synthetics/tests/trigger/ci') {
90
+ jest.spyOn(axios_1.default, 'create').mockImplementation((() => (request) => {
91
+ payloadMetadataSpy(request.data.metadata);
92
+ if (request.url === '/synthetics/tests/trigger/ci') {
94
93
  return { data: fakeTrigger };
95
94
  }
96
95
  }));
97
96
  yield utils.runTests(api, [{ public_id: fakeId, executionRule: interfaces_1.ExecutionRule.NON_BLOCKING }]);
98
- expect(payloadMetadataSpy).toHaveBeenCalledWith({
99
- ci: { job: {}, pipeline: {}, provider: {}, stage: {} },
100
- git: { commit: { author: {}, committer: {} } },
101
- trigger_app: 'npm_package',
102
- });
97
+ expect(payloadMetadataSpy).toHaveBeenCalledWith(undefined);
103
98
  const metadata = {
104
99
  ci: { job: { name: 'job' }, pipeline: {}, provider: { name: 'jest' }, stage: {} },
105
100
  git: { commit: { author: {}, committer: {}, message: 'test' } },
106
101
  };
107
102
  jest.spyOn(ciHelpers, 'getCIMetadata').mockImplementation(() => metadata);
103
+ yield utils.runTests(api, [{ public_id: fakeId, executionRule: interfaces_1.ExecutionRule.NON_BLOCKING }]);
104
+ expect(payloadMetadataSpy).toHaveBeenCalledWith(metadata);
105
+ }));
106
+ test('runTests api call includes trigger app header', () => __awaiter(void 0, void 0, void 0, function* () {
107
+ jest.spyOn(ciHelpers, 'getCIMetadata').mockImplementation(() => undefined);
108
+ const headersMetadataSpy = jest.fn();
109
+ jest.spyOn(axios_1.default, 'create').mockImplementation((() => (request) => {
110
+ headersMetadataSpy(request.headers);
111
+ if (request.url === '/synthetics/tests/trigger/ci') {
112
+ return { data: fakeTrigger };
113
+ }
114
+ }));
115
+ yield utils.runTests(api, [{ public_id: fakeId, executionRule: interfaces_1.ExecutionRule.NON_BLOCKING }]);
116
+ expect(headersMetadataSpy).toHaveBeenCalledWith(expect.objectContaining({ 'X-Trigger-App': 'npm_package' }));
108
117
  utils.setCiTriggerApp('unit_test');
109
118
  yield utils.runTests(api, [{ public_id: fakeId, executionRule: interfaces_1.ExecutionRule.NON_BLOCKING }]);
110
- expect(payloadMetadataSpy).toHaveBeenCalledWith(Object.assign(Object.assign({}, metadata), { trigger_app: 'unit_test' }));
119
+ expect(headersMetadataSpy).toHaveBeenCalledWith(expect.objectContaining({ 'X-Trigger-App': 'unit_test' }));
111
120
  }));
112
121
  test('should run test with publicId from url', () => __awaiter(void 0, void 0, void 0, function* () {
113
122
  const axiosMock = jest.spyOn(axios_1.default, 'create');
@@ -1,5 +1,5 @@
1
1
  import { AxiosError } from 'axios';
2
- import { APIConfiguration, Payload, PollResult, Test, TestSearchResult, Trigger } from './interfaces';
2
+ import { APIConfiguration, APIHelper } from './interfaces';
3
3
  interface BackendError {
4
4
  errors: string[];
5
5
  }
@@ -9,17 +9,8 @@ export declare class EndpointError extends Error {
9
9
  constructor(message: string, status: number);
10
10
  }
11
11
  export declare const formatBackendErrors: (requestError: AxiosError<BackendError>) => string;
12
+ export declare const isForbiddenError: (error: AxiosError | EndpointError) => boolean;
12
13
  export declare const isNotFoundError: (error: AxiosError | EndpointError) => boolean;
13
14
  export declare const is5xxError: (error: AxiosError | EndpointError) => boolean | 0 | undefined;
14
- export declare const apiConstructor: (configuration: APIConfiguration) => {
15
- getPresignedURL: (testIds: string[]) => Promise<{
16
- url: string;
17
- }>;
18
- getTest: (testId: string) => Promise<Test>;
19
- pollResults: (resultIds: string[]) => Promise<{
20
- results: PollResult[];
21
- }>;
22
- searchTests: (query: string) => Promise<TestSearchResult>;
23
- triggerTests: (data: Payload) => Promise<Trigger>;
24
- };
15
+ export declare const apiConstructor: (configuration: APIConfiguration) => APIHelper;
25
16
  export {};
@@ -9,7 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.apiConstructor = exports.is5xxError = exports.isNotFoundError = exports.formatBackendErrors = exports.EndpointError = void 0;
12
+ exports.apiConstructor = exports.is5xxError = exports.isNotFoundError = exports.isForbiddenError = exports.formatBackendErrors = exports.EndpointError = void 0;
13
13
  const querystring_1 = require("querystring");
14
14
  const utils_1 = require("../../helpers/utils");
15
15
  const utils_2 = require("./utils");
@@ -43,6 +43,7 @@ exports.formatBackendErrors = formatBackendErrors;
43
43
  const triggerTests = (request) => (data) => __awaiter(void 0, void 0, void 0, function* () {
44
44
  const resp = yield retryRequest({
45
45
  data,
46
+ headers: { 'X-Trigger-App': utils_2.ciTriggerApp },
46
47
  method: 'POST',
47
48
  url: '/synthetics/tests/trigger/ci',
48
49
  }, request);
@@ -88,6 +89,8 @@ const retryOn5xxErrors = (retries, error) => {
88
89
  }
89
90
  };
90
91
  const getErrorHttpStatus = (error) => { var _a; return 'status' in error ? error.status : (_a = error.response) === null || _a === void 0 ? void 0 : _a.status; };
92
+ const isForbiddenError = (error) => getErrorHttpStatus(error) === 403;
93
+ exports.isForbiddenError = isForbiddenError;
91
94
  const isNotFoundError = (error) => getErrorHttpStatus(error) === 404;
92
95
  exports.isNotFoundError = isNotFoundError;
93
96
  const is5xxError = (error) => {
@@ -142,6 +142,10 @@ class RunTestCommand extends clipanion_1.Command {
142
142
  reporter.log('No test to run.\n');
143
143
  break;
144
144
  // Critical command errors
145
+ case 'AUTHORIZATION_ERROR':
146
+ reporter.error(`\n${chalk_1.default.bgRed.bold(' ERROR: authorization error ')}\n${error.message}\n\n`);
147
+ reporter.log('Credentials refused, make sure `apiKey`, `appKey` and `datadogSite` are correct.\n');
148
+ break;
145
149
  case 'MISSING_APP_KEY':
146
150
  reporter.error(`Missing ${chalk_1.default.red.bold('DATADOG_APP_KEY')} in your environment.\n`);
147
151
  break;
@@ -1,8 +1,8 @@
1
1
  declare const nonCriticalErrorCodes: readonly ["NO_TESTS_TO_RUN", "NO_RESULTS_TO_POLL"];
2
- declare type NonCriticalCiErrorCode = typeof nonCriticalErrorCodes[number];
3
- declare const criticalErrorCodes: readonly ["UNAVAILABLE_TEST_CONFIG", "MISSING_API_KEY", "MISSING_APP_KEY", "UNAVAILABLE_TUNNEL_CONFIG", "TUNNEL_START_FAILED", "TRIGGER_TESTS_FAILED", "POLL_RESULTS_FAILED"];
4
- declare type CriticalCiErrorCode = typeof criticalErrorCodes[number];
5
- declare type CiErrorCode = NonCriticalCiErrorCode | CriticalCiErrorCode;
2
+ export declare type NonCriticalCiErrorCode = typeof nonCriticalErrorCodes[number];
3
+ declare const criticalErrorCodes: readonly ["AUTHORIZATION_ERROR", "MISSING_API_KEY", "MISSING_APP_KEY", "POLL_RESULTS_FAILED", "TRIGGER_TESTS_FAILED", "TUNNEL_START_FAILED", "UNAVAILABLE_TEST_CONFIG", "UNAVAILABLE_TUNNEL_CONFIG"];
4
+ export declare type CriticalCiErrorCode = typeof criticalErrorCodes[number];
5
+ export declare type CiErrorCode = NonCriticalCiErrorCode | CriticalCiErrorCode;
6
6
  export declare class CiError extends Error {
7
7
  code: CiErrorCode;
8
8
  constructor(code: CiErrorCode);
@@ -4,13 +4,14 @@ exports.CriticalError = exports.CiError = void 0;
4
4
  /* tslint:disable:max-classes-per-file */
5
5
  const nonCriticalErrorCodes = ['NO_TESTS_TO_RUN', 'NO_RESULTS_TO_POLL'];
6
6
  const criticalErrorCodes = [
7
- 'UNAVAILABLE_TEST_CONFIG',
7
+ 'AUTHORIZATION_ERROR',
8
8
  'MISSING_API_KEY',
9
9
  'MISSING_APP_KEY',
10
- 'UNAVAILABLE_TUNNEL_CONFIG',
11
- 'TUNNEL_START_FAILED',
12
- 'TRIGGER_TESTS_FAILED',
13
10
  'POLL_RESULTS_FAILED',
11
+ 'TRIGGER_TESTS_FAILED',
12
+ 'TUNNEL_START_FAILED',
13
+ 'UNAVAILABLE_TEST_CONFIG',
14
+ 'UNAVAILABLE_TUNNEL_CONFIG',
14
15
  ];
15
16
  class CiError extends Error {
16
17
  constructor(code) {
@@ -253,12 +253,9 @@ export interface ConfigOverride {
253
253
  };
254
254
  }
255
255
  export interface Payload {
256
- metadata?: SyntheticsMetadata;
256
+ metadata?: Metadata;
257
257
  tests: TestPayload[];
258
258
  }
259
- export declare type SyntheticsMetadata = Metadata & {
260
- trigger_app: string;
261
- };
262
259
  export interface TestPayload extends ConfigOverride {
263
260
  executionRule: ExecutionRule;
264
261
  public_id: string;
@@ -195,7 +195,7 @@ class DefaultReporter {
195
195
  this.write(error);
196
196
  }
197
197
  initErrors(errors) {
198
- this.write(errors.join('\n') + '\n');
198
+ this.write(errors.join('\n') + '\n\n');
199
199
  }
200
200
  log(log) {
201
201
  this.write(log);
@@ -246,7 +246,7 @@ class DefaultReporter {
246
246
  testsList.push('…');
247
247
  }
248
248
  const testsDisplay = chalk_1.default.gray(`(${testsList.join(', ')})`);
249
- this.write(`\nWaiting for ${chalk_1.default.bold.cyan(tests.length)} test result${tests.length > 1 ? 's' : ''} ${testsDisplay}…\n`);
249
+ this.write(`Waiting for ${chalk_1.default.bold.cyan(tests.length)} test result${tests.length > 1 ? 's' : ''} ${testsDisplay}…\n`);
250
250
  }
251
251
  testTrigger(test, testId, executionRule, config) {
252
252
  const idDisplay = `[${chalk_1.default.bold.dim(testId)}]`;
@@ -62,15 +62,5 @@ export declare const getTestsList: (api: APIHelper, config: SyntheticsCIConfig,
62
62
  id: string;
63
63
  suite: string | undefined;
64
64
  }[]>;
65
- export declare const getApiHelper: (config: SyntheticsCIConfig) => {
66
- getPresignedURL: (testIds: string[]) => Promise<{
67
- url: string;
68
- }>;
69
- getTest: (testId: string) => Promise<Test>;
70
- pollResults: (resultIds: string[]) => Promise<{
71
- results: PollResult[];
72
- }>;
73
- searchTests: (query: string) => Promise<import("./interfaces").TestSearchResult>;
74
- triggerTests: (data: import("./interfaces").Payload) => Promise<Trigger>;
75
- };
65
+ export declare const getApiHelper: (config: SyntheticsCIConfig) => APIHelper;
76
66
  export declare const getDatadogHost: (useIntake: boolean | undefined, config: SyntheticsCIConfig) => string;
@@ -32,7 +32,7 @@ const executeTests = (reporter, config) => __awaiter(void 0, void 0, void 0, fun
32
32
  testsToTrigger = yield exports.getTestsList(api, config, reporter);
33
33
  }
34
34
  catch (error) {
35
- throw new errors_1.CriticalError('UNAVAILABLE_TEST_CONFIG');
35
+ throw new errors_1.CriticalError(api_1.isForbiddenError(error) ? 'AUTHORIZATION_ERROR' : 'UNAVAILABLE_TEST_CONFIG');
36
36
  }
37
37
  }
38
38
  if (!testsToTrigger.length) {
@@ -43,7 +43,10 @@ const executeTests = (reporter, config) => __awaiter(void 0, void 0, void 0, fun
43
43
  testsToTriggerResult = yield utils_1.getTestsToTrigger(api, testsToTrigger, reporter);
44
44
  }
45
45
  catch (error) {
46
- throw error instanceof errors_1.CiError ? error : new errors_1.CriticalError('UNAVAILABLE_TEST_CONFIG');
46
+ if (error instanceof errors_1.CiError) {
47
+ throw error;
48
+ }
49
+ throw new errors_1.CriticalError(api_1.isForbiddenError(error) ? 'AUTHORIZATION_ERROR' : 'UNAVAILABLE_TEST_CONFIG');
47
50
  }
48
51
  const { tests, overriddenTestsToTrigger, summary } = testsToTriggerResult;
49
52
  // All tests have been skipped or are missing.
@@ -1,5 +1,6 @@
1
1
  import { APIHelper, ConfigOverride, ExecutionRule, InternalTest, MainReporter, PollResult, Reporter, Result, Suite, Summary, TestPayload, Trigger, TriggerConfig, TriggerResponse, TriggerResult } from './interfaces';
2
2
  import { Tunnel } from './tunnel';
3
+ export declare let ciTriggerApp: string;
3
4
  export declare const handleConfig: (test: InternalTest, publicId: string, reporter: MainReporter, config?: ConfigOverride | undefined) => TestPayload;
4
5
  export declare const setCiTriggerApp: (source: string) => void;
5
6
  export declare const getExecutionRule: (test: InternalTest, configOverride?: ConfigOverride | undefined) => ExecutionRule;