@elastic/synthetics 1.24.4 → 1.26.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.
Files changed (119) hide show
  1. package/README.md +67 -0
  2. package/dist/cli.js +3 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/common_types.d.ts +25 -0
  5. package/dist/common_types.d.ts.map +1 -1
  6. package/dist/core/certs.d.ts +55 -0
  7. package/dist/core/certs.d.ts.map +1 -0
  8. package/dist/core/certs.js +111 -0
  9. package/dist/core/certs.js.map +1 -0
  10. package/dist/core/gatherer.d.ts +4 -4
  11. package/dist/core/gatherer.d.ts.map +1 -1
  12. package/dist/core/gatherer.js +44 -7
  13. package/dist/core/gatherer.js.map +1 -1
  14. package/dist/core/globals.js +1 -1
  15. package/dist/core/index.d.ts +2 -1
  16. package/dist/core/index.d.ts.map +1 -1
  17. package/dist/core/index.js +16 -1
  18. package/dist/core/index.js.map +1 -1
  19. package/dist/core/runner.d.ts +1 -1
  20. package/dist/core/runner.d.ts.map +1 -1
  21. package/dist/core/runner.js +87 -58
  22. package/dist/core/runner.js.map +1 -1
  23. package/dist/dsl/api-journey.d.ts +62 -0
  24. package/dist/dsl/api-journey.d.ts.map +1 -0
  25. package/dist/dsl/api-journey.js +55 -0
  26. package/dist/dsl/api-journey.js.map +1 -0
  27. package/dist/dsl/index.d.ts +1 -0
  28. package/dist/dsl/index.d.ts.map +1 -1
  29. package/dist/dsl/index.js +1 -0
  30. package/dist/dsl/index.js.map +1 -1
  31. package/dist/dsl/journey.d.ts +10 -2
  32. package/dist/dsl/journey.d.ts.map +1 -1
  33. package/dist/dsl/journey.js +11 -2
  34. package/dist/dsl/journey.js.map +1 -1
  35. package/dist/dsl/monitor.d.ts +2 -1
  36. package/dist/dsl/monitor.d.ts.map +1 -1
  37. package/dist/dsl/monitor.js.map +1 -1
  38. package/dist/index.d.ts +2 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/loader.d.ts.map +1 -1
  43. package/dist/loader.js +66 -1
  44. package/dist/loader.js.map +1 -1
  45. package/dist/network-timings.d.ts +55 -0
  46. package/dist/network-timings.d.ts.map +1 -0
  47. package/dist/network-timings.js +107 -0
  48. package/dist/network-timings.js.map +1 -0
  49. package/dist/options.d.ts +1 -0
  50. package/dist/options.d.ts.map +1 -1
  51. package/dist/options.js +16 -0
  52. package/dist/options.js.map +1 -1
  53. package/dist/plugins/api-network.d.ts +39 -0
  54. package/dist/plugins/api-network.d.ts.map +1 -0
  55. package/dist/plugins/api-network.js +235 -0
  56. package/dist/plugins/api-network.js.map +1 -0
  57. package/dist/plugins/network.d.ts +0 -6
  58. package/dist/plugins/network.d.ts.map +1 -1
  59. package/dist/plugins/network.js +5 -70
  60. package/dist/plugins/network.js.map +1 -1
  61. package/dist/plugins/plugin-manager.d.ts +11 -4
  62. package/dist/plugins/plugin-manager.d.ts.map +1 -1
  63. package/dist/plugins/plugin-manager.js +36 -16
  64. package/dist/plugins/plugin-manager.js.map +1 -1
  65. package/dist/push/index.d.ts.map +1 -1
  66. package/dist/push/index.js +4 -0
  67. package/dist/push/index.js.map +1 -1
  68. package/dist/push/monitor.d.ts.map +1 -1
  69. package/dist/push/monitor.js +1 -1
  70. package/dist/push/monitor.js.map +1 -1
  71. package/dist/push/run-local.d.ts.map +1 -1
  72. package/dist/push/run-local.js +15 -3
  73. package/dist/push/run-local.js.map +1 -1
  74. package/dist/push/utils.d.ts +7 -0
  75. package/dist/push/utils.d.ts.map +1 -1
  76. package/dist/push/utils.js +15 -1
  77. package/dist/push/utils.js.map +1 -1
  78. package/dist/reporters/base.d.ts +2 -2
  79. package/dist/reporters/base.d.ts.map +1 -1
  80. package/dist/reporters/base.js.map +1 -1
  81. package/dist/reporters/index.d.ts +2 -2
  82. package/dist/reporters/index.d.ts.map +1 -1
  83. package/dist/reporters/index.js.map +1 -1
  84. package/dist/reporters/json.d.ts +6 -2
  85. package/dist/reporters/json.d.ts.map +1 -1
  86. package/dist/reporters/json.js +68 -33
  87. package/dist/reporters/json.js.map +1 -1
  88. package/package.json +11 -21
  89. package/src/cli.ts +3 -0
  90. package/src/common_types.ts +34 -0
  91. package/src/core/certs.ts +122 -0
  92. package/src/core/gatherer.ts +63 -10
  93. package/src/core/globals.ts +1 -1
  94. package/src/core/index.ts +28 -0
  95. package/src/core/runner.ts +94 -67
  96. package/src/dsl/api-journey.ts +93 -0
  97. package/src/dsl/index.ts +1 -0
  98. package/src/dsl/journey.ts +21 -9
  99. package/src/dsl/monitor.ts +2 -0
  100. package/src/index.ts +6 -0
  101. package/src/loader.ts +78 -2
  102. package/src/network-timings.ts +130 -0
  103. package/src/options.ts +25 -0
  104. package/src/plugins/api-network.ts +252 -0
  105. package/src/plugins/network.ts +5 -87
  106. package/src/plugins/plugin-manager.ts +60 -19
  107. package/src/push/index.ts +11 -0
  108. package/src/push/monitor.ts +6 -2
  109. package/src/push/run-local.ts +25 -15
  110. package/src/push/utils.ts +15 -0
  111. package/src/reporters/base.ts +2 -1
  112. package/src/reporters/index.ts +2 -1
  113. package/src/reporters/json.ts +74 -41
  114. package/templates/README.md +3 -1
  115. package/templates/journeys/advanced-api-example-helpers.ts +79 -0
  116. package/templates/journeys/advanced-api-example.journey.ts +52 -0
  117. package/templates/journeys/api-example.journey.ts +35 -0
  118. package/templates/synthetics.config.ts +2 -0
  119. package/.husky/install.mjs +0 -7
package/src/cli.ts CHANGED
@@ -74,6 +74,7 @@ const {
74
74
  match,
75
75
  fields,
76
76
  maintenanceWindows,
77
+ certificateErrorSpkiAllowlist,
77
78
  } = getCommonCommandOpts();
78
79
 
79
80
  program
@@ -128,6 +129,7 @@ program
128
129
  '--ignore-https-errors',
129
130
  'ignores any HTTPS errors in sites being tested, including ones related to unrecognized certs or signatures. This can be insecure!'
130
131
  )
132
+ .addOption(certificateErrorSpkiAllowlist)
131
133
  .option(
132
134
  '--quiet-exit-code',
133
135
  'always return 0 as an exit code status, regardless of test pass / fail. Only return > 0 exit codes on internal errors where the suite could not be run'
@@ -232,6 +234,7 @@ program
232
234
  .addOption(match)
233
235
  .addOption(params)
234
236
  .addOption(playwrightOpts)
237
+ .addOption(certificateErrorSpkiAllowlist)
235
238
  .addOption(configOpt)
236
239
  .addOption(maintenanceWindows)
237
240
  .action(async cmdOpts => {
@@ -64,6 +64,10 @@ export type NetworkConditions = {
64
64
  latency: number;
65
65
  };
66
66
 
67
+ export type APIDriver = {
68
+ request: APIRequestContext;
69
+ };
70
+
67
71
  export type Driver = {
68
72
  browser: ChromiumBrowser;
69
73
  context: ChromiumBrowserContext;
@@ -208,6 +212,16 @@ type GrepOptions = {
208
212
  match?: string;
209
213
  };
210
214
 
215
+ /**
216
+ * PEM certificates (or Buffers) used to build Chromium's SPKI certificate-error
217
+ * allowlist. This does not add a CA to Chromium's trust store: matching
218
+ * certificates bypass certificate validation errors.
219
+ */
220
+ export type CertificateErrorSpkiAllowlist =
221
+ | string
222
+ | Buffer
223
+ | Array<string | Buffer>;
224
+
211
225
  type BaseArgs = {
212
226
  params?: Params;
213
227
  screenshots?: ScreenshotOptions;
@@ -217,6 +231,7 @@ type BaseArgs = {
217
231
  outfd?: number;
218
232
  wsEndpoint?: string;
219
233
  pauseOnError?: boolean;
234
+ certificateErrorSpkiAllowlist?: CertificateErrorSpkiAllowlist;
220
235
  playwrightOptions?: PlaywrightOptions;
221
236
  quietExitCode?: boolean;
222
237
  throttling?: MonitorConfig['throttling'];
@@ -256,6 +271,13 @@ export type RunOptions = BaseArgs & {
256
271
  grepOpts?: GrepOptions;
257
272
  };
258
273
 
274
+ export type APIRunOptions = BaseArgs & {
275
+ network?: boolean;
276
+ environment?: string;
277
+ reporter?: BuiltInReporterName | ReporterInstance;
278
+ grepOpts?: GrepOptions;
279
+ };
280
+
259
281
  export type PushOptions = Partial<ProjectSettings> &
260
282
  Partial<BaseArgs> & {
261
283
  auth: string;
@@ -289,6 +311,13 @@ export type SyntheticsConfig = {
289
311
  monitor?: MonitorConfig;
290
312
  project?: ProjectSettings;
291
313
  proxy?: ProxySettings;
314
+ certificateErrorSpkiAllowlist?: CertificateErrorSpkiAllowlist;
315
+ };
316
+
317
+ /** Runner Payload types */
318
+ export type APIJourneyResult = Partial<Journey> & {
319
+ networkinfo?: PluginOutput['networkinfo'];
320
+ stepsresults?: Array<StepResult>;
292
321
  };
293
322
 
294
323
  /** Runner Payload types */
@@ -330,6 +359,11 @@ export type JourneyEndResult = JourneyStartResult &
330
359
  options: RunOptions;
331
360
  };
332
361
 
362
+ export type APIJourneyEndResult = JourneyStartResult &
363
+ APIJourneyResult & {
364
+ options: RunOptions;
365
+ };
366
+
333
367
  export type StepEndResult = StepResult;
334
368
 
335
369
  export type ProxySettings = {
@@ -0,0 +1,122 @@
1
+ /**
2
+ * MIT License
3
+ *
4
+ * Copyright (c) 2020-present, Elastic NV
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ * of this software and associated documentation files (the "Software"), to deal
8
+ * in the Software without restriction, including without limitation the rights
9
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ * copies of the Software, and to permit persons to whom the Software is
11
+ * furnished to do so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in
14
+ * all copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ * THE SOFTWARE.
23
+ *
24
+ */
25
+
26
+ import { X509Certificate, createHash } from 'crypto';
27
+ import { CertificateErrorSpkiAllowlist } from '../common_types';
28
+ import { warn } from '../helpers';
29
+
30
+ const PEM_CERTIFICATE_RE =
31
+ /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;
32
+
33
+ /**
34
+ * Normalize user-provided PEM certificates (string, Buffer, or an array of
35
+ * either) into a flat list of PEM strings. Each entry may itself be a bundle
36
+ * that contains more than one certificate.
37
+ */
38
+ export function normalizeCertificateErrorSpkiAllowlist(
39
+ certificates?: CertificateErrorSpkiAllowlist
40
+ ): string[] {
41
+ if (certificates == null) {
42
+ return [];
43
+ }
44
+ const entries = Array.isArray(certificates) ? certificates : [certificates];
45
+ return entries
46
+ .map(entry => (Buffer.isBuffer(entry) ? entry.toString('utf-8') : entry))
47
+ .filter((entry): entry is string => Boolean(entry && entry.trim()));
48
+ }
49
+
50
+ /**
51
+ * Split a PEM bundle into the individual certificates it contains. Node's
52
+ * `X509Certificate` only parses the first certificate of a bundle, so we have
53
+ * to slice them apart ourselves to support full chains / multiple CAs.
54
+ */
55
+ export function splitPemCertificates(pem: string): string[] {
56
+ return pem.match(PEM_CERTIFICATE_RE) ?? [];
57
+ }
58
+
59
+ /**
60
+ * Compute the base64 encoded SHA-256 fingerprint of a certificate's
61
+ * SubjectPublicKeyInfo (SPKI). This is the exact value Chromium expects in the
62
+ * `--ignore-certificate-errors-spki-list` switch.
63
+ */
64
+ export function getSpkiFingerprint(pem: string): string {
65
+ const der = new X509Certificate(pem).publicKey.export({
66
+ type: 'spki',
67
+ format: 'der',
68
+ });
69
+ return createHash('sha256').update(der).digest('base64');
70
+ }
71
+
72
+ /**
73
+ * Describe an entry that failed to parse as a certificate for use in a
74
+ * warning message. A raw file path (e.g. from a typo'd
75
+ * `certificateErrorSpkiAllowlist` entry that didn't match an existing file) is
76
+ * shown as-is since that's the actionable detail; inline PEM content is not,
77
+ * to avoid dumping key material to the console.
78
+ */
79
+ function describeInvalidCertificateEntry(candidate: string): string {
80
+ if (candidate.includes('-----BEGIN CERTIFICATE-----')) {
81
+ return 'an inline PEM certificate';
82
+ }
83
+ const preview =
84
+ candidate.length > 80 ? `${candidate.slice(0, 80)}...` : candidate;
85
+ return `"${preview}"`;
86
+ }
87
+
88
+ /**
89
+ * Build the list of SPKI fingerprints for all supplied certificates. Chromium
90
+ * uses this list to bypass certificate errors for matching presented
91
+ * certificates; it does not establish CA trust. Invalid certificates are
92
+ * skipped, with a warning, so a single bad entry never aborts the whole run
93
+ * but also never fails silently - a `certificateErrorSpkiAllowlist` entry that
94
+ * doesn't resolve to a real file and isn't valid PEM (e.g. a typo'd path)
95
+ * would otherwise never be allowlisted, and matching endpoints would keep
96
+ * failing certificate validation with no indication why.
97
+ */
98
+ export function getSpkiFingerprints(
99
+ certificates?: CertificateErrorSpkiAllowlist
100
+ ): string[] {
101
+ const fingerprints = new Set<string>();
102
+ for (const entry of normalizeCertificateErrorSpkiAllowlist(certificates)) {
103
+ const pemCertificates = splitPemCertificates(entry);
104
+ // Fall back to treating the whole entry as a single certificate when no
105
+ // PEM boundary markers are present.
106
+ const candidates = pemCertificates.length > 0 ? pemCertificates : [entry];
107
+ for (const certificate of candidates) {
108
+ try {
109
+ fingerprints.add(getSpkiFingerprint(certificate));
110
+ } catch (e) {
111
+ warn(
112
+ `certificateErrorSpkiAllowlist: could not parse ${describeInvalidCertificateEntry(
113
+ certificate
114
+ )} as a certificate - it will not be allowlisted and matching endpoints may still fail certificate validation (${
115
+ e.message
116
+ })`
117
+ );
118
+ }
119
+ }
120
+ }
121
+ return [...fingerprints];
122
+ }
@@ -32,7 +32,13 @@ import {
32
32
  } from 'playwright-core';
33
33
  import { PluginManager } from '../plugins';
34
34
  import { log } from './logger';
35
- import { Driver, NetworkConditions, RunOptions } from '../common_types';
35
+ import { getSpkiFingerprints } from './certs';
36
+ import {
37
+ APIDriver,
38
+ Driver,
39
+ NetworkConditions,
40
+ RunOptions,
41
+ } from '../common_types';
36
42
 
37
43
  // Default timeout for Playwright actions and Navigations
38
44
  const DEFAULT_TIMEOUT = 50000;
@@ -56,10 +62,31 @@ export class Gatherer {
56
62
  log(`Gatherer: connecting to WS endpoint: ${wsEndpoint}`);
57
63
  Gatherer.browser = await chromium.connect(wsEndpoint);
58
64
  } else {
65
+ /**
66
+ * Chromium on Linux has its own NSS trust store. This flag does not add
67
+ * a CA to that store: it bypasses certificate errors only when a
68
+ * presented certificate's SPKI matches one of the supplied fingerprints.
69
+ * It is narrower than `ignoreHTTPSErrors`, but is not CA trust.
70
+ */
71
+ const spkiFingerprints = getSpkiFingerprints(
72
+ options.certificateErrorSpkiAllowlist
73
+ );
74
+ if (spkiFingerprints.length > 0) {
75
+ log(
76
+ `Gatherer: bypassing certificate errors for ${spkiFingerprints.length} allowlisted SPKI fingerprint(s)`
77
+ );
78
+ }
59
79
  Gatherer.browser = await chromium.launch({
60
80
  ...playwrightOptions,
61
81
  args: [
62
82
  ...(playwrightOptions?.headless ? ['--disable-gpu'] : []),
83
+ ...(spkiFingerprints.length > 0
84
+ ? [
85
+ `--ignore-certificate-errors-spki-list=${spkiFingerprints.join(
86
+ ','
87
+ )}`,
88
+ ]
89
+ : []),
63
90
  ...(playwrightOptions?.args ?? []),
64
91
  ],
65
92
  });
@@ -71,9 +98,19 @@ export class Gatherer {
71
98
  });
72
99
  }
73
100
 
74
- static async setupDriver(options: RunOptions): Promise<Driver> {
75
- await Gatherer.launchBrowser(options);
101
+ static async setupDriver<T extends 'browser' | 'api' = 'browser'>(
102
+ options: RunOptions,
103
+ journeyType: T = 'browser' as T
104
+ ): Promise<T extends 'api' ? APIDriver : Driver> {
76
105
  const { playwrightOptions } = options;
106
+
107
+ if (journeyType === 'api') {
108
+ const request = await apiRequest.newContext({ ...playwrightOptions });
109
+ return { request } as T extends 'api' ? APIDriver : Driver;
110
+ }
111
+
112
+ await Gatherer.launchBrowser(options);
113
+
77
114
  const context = await Gatherer.browser.newContext({
78
115
  ...playwrightOptions,
79
116
  userAgent: await Gatherer.getUserAgent(playwrightOptions?.userAgent),
@@ -96,7 +133,13 @@ export class Gatherer {
96
133
  const page = await context.newPage();
97
134
  const client = await context.newCDPSession(page);
98
135
  const request = await apiRequest.newContext({ ...playwrightOptions });
99
- return { browser: Gatherer.browser, context, page, client, request };
136
+ return {
137
+ browser: Gatherer.browser,
138
+ context,
139
+ page,
140
+ client,
141
+ request,
142
+ } as T extends 'api' ? APIDriver : Driver;
100
143
  }
101
144
 
102
145
  static async getUserAgent(userAgent?: string) {
@@ -136,14 +179,22 @@ export class Gatherer {
136
179
  * Starts recording all events related to the v8 devtools protocol
137
180
  * https://chromedevtools.github.io/devtools-protocol/v8/
138
181
  */
139
- static async beginRecording(driver: Driver, options: RunOptions) {
182
+ static async beginRecording(driver: Driver | APIDriver, options: RunOptions) {
140
183
  log('Gatherer: started recording');
141
184
  const { network, metrics } = options;
142
185
  Gatherer.pluginManager = new PluginManager(driver);
143
186
  Gatherer.pluginManager.registerAll(options);
144
- const plugins = [await Gatherer.pluginManager.start('browserconsole')];
145
- network && plugins.push(await Gatherer.pluginManager.start('network'));
146
- metrics && plugins.push(await Gatherer.pluginManager.start('performance'));
187
+ const plugins = [];
188
+ if ('browser' in driver) {
189
+ plugins.push(await Gatherer.pluginManager.start('browserconsole'));
190
+ network && plugins.push(await Gatherer.pluginManager.start('network'));
191
+ metrics &&
192
+ plugins.push(await Gatherer.pluginManager.start('performance'));
193
+ } else {
194
+ // Network is the only data source for API journeys, so always capture
195
+ // it regardless of `options.network`.
196
+ plugins.push(await Gatherer.pluginManager.start('network'));
197
+ }
147
198
  await Promise.all(plugins);
148
199
  return Gatherer.pluginManager;
149
200
  }
@@ -153,10 +204,12 @@ export class Gatherer {
153
204
  await Gatherer.pluginManager.unregisterAll();
154
205
  }
155
206
 
156
- static async dispose(driver: Driver) {
207
+ static async dispose(driver: Driver | APIDriver) {
157
208
  log(`Gatherer: closing all contexts`);
158
209
  await driver.request.dispose();
159
- await driver.context.close();
210
+ if ('context' in driver) {
211
+ await driver.context.close();
212
+ }
160
213
  }
161
214
 
162
215
  static async stop() {
@@ -26,7 +26,7 @@
26
26
  import Runner from './runner';
27
27
 
28
28
  /**
29
- * Use a gloabl Runner which would be accessed by the runtime and
29
+ * Use a global Runner which would be accessed by the runtime and
30
30
  * required to handle the local vs global invocation through CLI
31
31
  */
32
32
  const SYNTHETICS_RUNNER = Symbol.for('SYNTHETICS_RUNNER');
package/src/core/index.ts CHANGED
@@ -24,6 +24,10 @@
24
24
  */
25
25
 
26
26
  import {
27
+ APIJourney,
28
+ APIJourneyCallback,
29
+ APIJourneyOptions,
30
+ APIJourneyWithAnnotations,
27
31
  Journey,
28
32
  JourneyCallback,
29
33
  JourneyOptions,
@@ -60,6 +64,30 @@ export const journey = createJourney() as JourneyWithAnnotations;
60
64
  journey.skip = createJourney('skip');
61
65
  journey.only = createJourney('only');
62
66
 
67
+ const createAPIJourney = (type?: 'skip' | 'only') =>
68
+ wrapFnWithLocation(
69
+ (
70
+ location: Location,
71
+ options: APIJourneyOptions | string,
72
+ callback: APIJourneyCallback
73
+ ) => {
74
+ log(`API Journey register: ${JSON.stringify(options)}`);
75
+ if (typeof options === 'string') {
76
+ options = { name: options, id: options };
77
+ }
78
+ const j = new APIJourney(options, callback, location);
79
+ if (type) {
80
+ j[type] = true;
81
+ }
82
+ runner._addJourney(j);
83
+ return j;
84
+ }
85
+ );
86
+
87
+ export const apiJourney = createAPIJourney() as APIJourneyWithAnnotations;
88
+ apiJourney.skip = createAPIJourney('skip');
89
+ apiJourney.only = createAPIJourney('only');
90
+
63
91
  const createStep = (type?: 'skip' | 'soft' | 'only') =>
64
92
  wrapFnWithLocation(
65
93
  (location: Location, name: string, callback: VoidCallback) => {
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { join } from 'path';
27
27
  import { mkdir, rm, writeFile } from 'fs/promises';
28
- import { Journey } from '../dsl/journey';
28
+ import { Journey, JourneyCallbackOpts } from '../dsl/journey';
29
29
  import { Step } from '../dsl/step';
30
30
  import { reporters, Reporter } from '../reporters';
31
31
  import {
@@ -44,6 +44,7 @@ import {
44
44
  JourneyResult,
45
45
  StepResult,
46
46
  PushOptions,
47
+ APIDriver,
47
48
  } from '../common_types';
48
49
  import { PerformanceManager, filterBrowserMessages } from '../plugins';
49
50
  import { Gatherer } from './gatherer';
@@ -66,7 +67,7 @@ export interface RunnerInfo {
66
67
  */
67
68
  readonly currentJourney: Journey | undefined;
68
69
  /**
69
- * All registerd journeys
70
+ * All registered journeys
70
71
  */
71
72
  readonly journeys: Journey[];
72
73
  }
@@ -78,7 +79,7 @@ export default class Runner implements RunnerInfo {
78
79
  #journeys: Journey[] = [];
79
80
  #hooks: SuiteHooks = { beforeAll: [], afterAll: [] };
80
81
  #screenshotPath = join(CACHE_PATH, 'screenshots');
81
- #driver?: Driver;
82
+ #driver?: Driver | APIDriver;
82
83
  #browserDelay = -1;
83
84
  #hookError: Error | undefined;
84
85
  #monitor?: Monitor;
@@ -210,18 +211,22 @@ export default class Runner implements RunnerInfo {
210
211
  async #runStep(step: Step, options: RunOptions): Promise<StepResult> {
211
212
  log(`Runner: start step (${step.name})`);
212
213
  const { metrics, screenshots, filmstrips, trace } = options;
213
- /**
214
- * URL needs to be the first navigation request of any step
215
- * Listening for request solves the case where `about:blank` would be
216
- * reported for failed navigations
217
- */
218
- const captureUrl = req => {
219
- if (!step.url && req.isNavigationRequest()) {
220
- step.url = req.url();
221
- }
222
- this.#driver.context.off('request', captureUrl);
223
- };
224
- this.#driver.context.on('request', captureUrl);
214
+
215
+ if ('context' in this.#driver) {
216
+ /**
217
+ * URL needs to be the first navigation request of any step
218
+ * Listening for request solves the case where `about:blank` would be
219
+ * reported for failed navigations
220
+ */
221
+ const captureUrl = req => {
222
+ if (!step.url && req.isNavigationRequest()) {
223
+ step.url = req.url();
224
+ }
225
+ 'context' in this.#driver &&
226
+ this.#driver.context.off('request', captureUrl);
227
+ };
228
+ this.#driver.context.on('request', captureUrl);
229
+ }
225
230
 
226
231
  const data: StepResult = {};
227
232
  const traceEnabled = trace || filmstrips;
@@ -240,30 +245,32 @@ export default class Runner implements RunnerInfo {
240
245
  step.status = 'failed';
241
246
  step.error = error;
242
247
  } finally {
243
- /**
244
- * Collect all step level metrics and trace events
245
- */
246
- if (metrics) {
247
- data.pagemetrics = await (
248
- Gatherer.pluginManager.get('performance') as PerformanceManager
249
- ).getMetrics();
250
- }
251
- if (traceEnabled) {
252
- const traceOutput = await Gatherer.pluginManager.stop('trace');
253
- Object.assign(data, traceOutput);
254
- }
255
- /**
256
- * Capture screenshot for the newly created pages
257
- * via popup or new windows/tabs
258
- *
259
- * Last open page will get us the correct screenshot
260
- */
261
- const pages = this.#driver.context.pages();
262
- const page = pages[pages.length - 1];
263
- if (page) {
264
- step.url ??= page.url();
265
- if (screenshots && screenshots !== 'off') {
266
- await this.captureScreenshot(page, step);
248
+ if ('context' in this.#driver) {
249
+ /**
250
+ * Collect all step level metrics and trace events
251
+ */
252
+ if (metrics) {
253
+ data.pagemetrics = await (
254
+ Gatherer.pluginManager.get('performance') as PerformanceManager
255
+ ).getMetrics();
256
+ }
257
+ if (traceEnabled) {
258
+ const traceOutput = await Gatherer.pluginManager.stop('trace');
259
+ Object.assign(data, traceOutput);
260
+ }
261
+ /**
262
+ * Capture screenshot for the newly created pages
263
+ * via popup or new windows/tabs
264
+ *
265
+ * Last open page will get us the correct screenshot
266
+ */
267
+ const pages = this.#driver.context.pages();
268
+ const page = pages[pages.length - 1];
269
+ if (page) {
270
+ step.url ??= page.url();
271
+ if (screenshots && screenshots !== 'off') {
272
+ await this.captureScreenshot(page, step);
273
+ }
267
274
  }
268
275
  }
269
276
  }
@@ -310,22 +317,24 @@ export default class Runner implements RunnerInfo {
310
317
 
311
318
  async #startJourney(journey: Journey, options: RunOptions) {
312
319
  journey._startTime = monotonicTimeInSeconds();
313
- this.#driver = await Gatherer.setupDriver(options);
320
+ this.#driver = await Gatherer.setupDriver(options, journey.type);
314
321
  await Gatherer.beginRecording(this.#driver, options);
315
- /**
316
- * For each journey we create the screenshots folder for
317
- * caching all screenshots and clear them at end of each journey
318
- */
319
- await mkdir(this.#screenshotPath, { recursive: true });
322
+ if (journey.type === 'browser') {
323
+ /**
324
+ * For each journey we create the screenshots folder for
325
+ * caching all screenshots and clear them at end of each journey
326
+ */
327
+ await mkdir(this.#screenshotPath, { recursive: true });
328
+ }
320
329
  const params = options.params;
321
330
  this.#reporter?.onJourneyStart?.(journey, {
322
331
  timestamp: getTimestamp(),
323
332
  params,
324
333
  });
325
334
  /**
326
- * Exeucute the journey callback which registers the steps for current journey
335
+ * Execute the journey callback which registers the steps for current journey
327
336
  */
328
- journey.cb({ ...this.#driver, params, info: this });
337
+ journey.cb({ ...this.#driver, params, info: this } as JourneyCallbackOpts);
329
338
  }
330
339
 
331
340
  async #endJourney(
@@ -335,24 +344,34 @@ export default class Runner implements RunnerInfo {
335
344
  ) {
336
345
  // Enhance the journey results
337
346
  const pOutput = await Gatherer.pluginManager.output();
338
- const bConsole = filterBrowserMessages(
339
- pOutput.browserconsole,
340
- journey.status
341
- );
342
- await this.#reporter?.onJourneyEnd?.(journey, {
343
- browserDelay: this.#browserDelay,
344
- timestamp: getTimestamp(),
345
- options,
346
- networkinfo: pOutput.networkinfo,
347
- browserconsole: bConsole,
348
- });
347
+ const isBrowserJourney = journey.type === 'browser';
348
+ const bConsole = isBrowserJourney
349
+ ? filterBrowserMessages(pOutput.browserconsole, journey.status)
350
+ : undefined;
351
+ if (isBrowserJourney) {
352
+ await this.#reporter?.onJourneyEnd?.(journey, {
353
+ browserDelay: this.#browserDelay,
354
+ timestamp: getTimestamp(),
355
+ options,
356
+ networkinfo: pOutput.networkinfo,
357
+ browserconsole: bConsole,
358
+ });
359
+ } else {
360
+ await this.#reporter?.onJourneyEnd?.(journey, {
361
+ timestamp: getTimestamp(),
362
+ options,
363
+ networkinfo: pOutput.networkinfo,
364
+ });
365
+ }
349
366
  await Gatherer.endRecording();
350
367
  await Gatherer.dispose(this.#driver);
351
368
  // clear screenshots cache after each journey
352
- await rm(this.#screenshotPath, { recursive: true, force: true });
369
+ if (isBrowserJourney) {
370
+ await rm(this.#screenshotPath, { recursive: true, force: true });
371
+ }
353
372
  return Object.assign(result, {
354
373
  networkinfo: pOutput.networkinfo,
355
- browserconsole: bConsole,
374
+ ...(isBrowserJourney ? { browserconsole: bConsole } : {}),
356
375
  ...journey,
357
376
  });
358
377
  }
@@ -438,6 +457,7 @@ export default class Runner implements RunnerInfo {
438
457
  privateLocations: options.privateLocations,
439
458
  params: options.params,
440
459
  playwrightOptions: options.playwrightOptions,
460
+ certificateErrorSpkiAllowlist: options.certificateErrorSpkiAllowlist,
441
461
  screenshot: options.screenshots,
442
462
  tags: options.tags,
443
463
  alert: options.alert,
@@ -516,9 +536,9 @@ export default class Runner implements RunnerInfo {
516
536
  const { dryRun, grepOpts } = options;
517
537
 
518
538
  // collect all journeys with `.only` annotation and skip the rest
519
- const onlyJournerys = this.#journeys.filter(j => j.only);
520
- if (onlyJournerys.length > 0) {
521
- this.#journeys = onlyJournerys;
539
+ const onlyJourneys = this.#journeys.filter(j => j.only);
540
+ if (onlyJourneys.length > 0) {
541
+ this.#journeys = onlyJourneys;
522
542
  } else {
523
543
  // filter journeys based on tags and skip annotations
524
544
  this.#journeys = this.#journeys.filter(
@@ -551,9 +571,16 @@ export default class Runner implements RunnerInfo {
551
571
 
552
572
  async _runJourneys(options: RunOptions) {
553
573
  const result: RunResult = {};
554
- const browserStart = monotonicTimeInSeconds();
555
- await Gatherer.launchBrowser(options);
556
- this.#browserDelay = monotonicTimeInSeconds() - browserStart;
574
+ /**
575
+ * Only spin up a Chromium browser if at least one browser journey is
576
+ * scheduled to run. Pure API-journey suites skip launch entirely.
577
+ */
578
+ const hasBrowserJourneys = this.#journeys.some(j => j.type === 'browser');
579
+ if (hasBrowserJourneys) {
580
+ const browserStart = monotonicTimeInSeconds();
581
+ await Gatherer.launchBrowser(options);
582
+ this.#browserDelay = monotonicTimeInSeconds() - browserStart;
583
+ }
557
584
 
558
585
  for (const journey of this.#journeys) {
559
586
  const journeyResult: JourneyResult = this.#hookError
@@ -578,7 +605,7 @@ export default class Runner implements RunnerInfo {
578
605
  this.#journeys = [];
579
606
  this.#active = false;
580
607
  /**
581
- * Clear all cache data stored for post processing by
608
+ * Clear all cache data stored for post-processing by
582
609
  * the current synthetic agent run
583
610
  */
584
611
  await rm(CACHE_PATH, { recursive: true, force: true });