@appliqation/automation-sdk 2.7.0 → 2.8.1

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.
@@ -1,5 +1,6 @@
1
1
  const path = require('path');
2
2
  const AppliqationClient = require('../../AppliqationClient');
3
+ const ScopeValidator = require('../../services/ScopeValidator');
3
4
  const UuidExtractor = require('./helpers/UuidExtractor');
4
5
  const DeviceOsDetector = require('./helpers/DeviceOsDetector');
5
6
  const PayloadBuilder = require('../../utils/PayloadBuilder');
@@ -176,6 +177,21 @@ class AppliqationReporter {
176
177
  this.executionStartTime = null;
177
178
  this.executionEndTime = null;
178
179
  this.playwrightOutputDir = null;
180
+
181
+ // Playwright does not reliably await a reporter's onTestEnd() return
182
+ // value before calling onEnd() — only onEnd()'s own promise is
183
+ // guaranteed to be awaited. onTestEnd() can itself wait (via
184
+ // waitForRunMatrix) for a still-in-flight run creation, so without
185
+ // this tracking, onEnd() can start reading resultsByRun/orphansByRun
186
+ // before onTestEnd has finished populating them, silently submitting
187
+ // nothing. Every onTestEnd() invocation pushes its own promise here;
188
+ // onEnd() awaits them all first.
189
+ this.pendingTestEndPromises = [];
190
+
191
+ // C5 — scope validation state
192
+ this.scopeFilterEnabled = false; // true when strategy === 'filter'
193
+ this.inScopeUuids = null; // Set<string>, null = no filter
194
+ this.droppedOutOfScopeCount = 0;
179
195
  }
180
196
 
181
197
  /**
@@ -211,6 +227,37 @@ class AppliqationReporter {
211
227
  }
212
228
  }
213
229
 
230
+ // Extract every Appliqation-mapped UUID from the suite once, then
231
+ // share with scope validation (C5) and run pre-population (C4/E3).
232
+ // Single walk over the test tree, single source of truth.
233
+ const allSuiteUuids = ScopeValidator.prototype.extractUuidsFromSuite.call(
234
+ { /* no scope needed for extraction */ }, suite
235
+ );
236
+
237
+ // C5 — pre-execution scope validation.
238
+ // If the user set scenarioId or testSetId, verify every test about to
239
+ // run actually belongs to that scope. Mismatch → apply the configured
240
+ // resolution strategy (cancel / adhoc / filter). This is the SDK's
241
+ // primary defence against silent data corruption: without it, a
242
+ // tag-based selection that spans scenarios writes to the wrong run.
243
+ const scopeOutcome = await this._runScopeValidation(suite, allSuiteUuids);
244
+ if (scopeOutcome.cancelled) {
245
+ // User chose cancel (or default in CI on mismatch). Skip run
246
+ // creation and result submission entirely; tests still execute
247
+ // locally so the user keeps their own Playwright output.
248
+ this.appqEnabled = false;
249
+ return;
250
+ }
251
+
252
+ // C4 / E3 — UUIDs to pre-populate on the run document. If the filter
253
+ // strategy applied, only the in-scope subset is sent; otherwise the
254
+ // full extracted set. Backend stores these in data[] so the UI grid
255
+ // and orphan detector know which TCs belong to the run before any
256
+ // result lands.
257
+ this.preExecutionUuids = this.scopeFilterEnabled && this.inScopeUuids
258
+ ? Array.from(this.inScopeUuids)
259
+ : allSuiteUuids;
260
+
214
261
  if (!this.config.autoCreateRun) {
215
262
  logger.info('Auto-create run disabled. Skipping run matrix creation.');
216
263
  return;
@@ -276,7 +323,11 @@ class AppliqationReporter {
276
323
  browsers: matrixConfig.browsers,
277
324
  device: matrixConfig.device,
278
325
  os: matrixConfig.os,
279
- title: this.config.title
326
+ title: this.config.title,
327
+ // C4 / E3 — pre-populate the run's data[] array with the
328
+ // UUIDs about to execute. Same set across all matrix configs
329
+ // (Playwright reruns the same tests per browser project).
330
+ uuids: this.preExecutionUuids
280
331
  };
281
332
 
282
333
  const run = await this.client.createRun(runOptions);
@@ -364,14 +415,36 @@ class AppliqationReporter {
364
415
  }
365
416
 
366
417
  /**
367
- * Called after a test completes — non-blocking
418
+ * Called after a test completes.
419
+ *
420
+ * Playwright does not reliably await this method's returned promise
421
+ * before calling onEnd() — so the actual work (which can wait on a
422
+ * still-in-flight run creation via waitForRunMatrix) runs in
423
+ * _processTestEnd(), and this wrapper only tracks that promise in
424
+ * pendingTestEndPromises so onEnd() can await it explicitly.
368
425
  */
369
- async onTestEnd(test, result) {
426
+ onTestEnd(test, result) {
370
427
  if (!this.appqEnabled) return;
428
+ const promise = this._processTestEnd(test, result);
429
+ this.pendingTestEndPromises.push(promise);
430
+ return promise;
431
+ }
371
432
 
433
+ /** @private */
434
+ async _processTestEnd(test, result) {
372
435
  try {
373
436
  const uuid = UuidExtractor.extractFromAnnotations(result.annotations || []) || UuidExtractor.extractFromTest(test);
374
437
 
438
+ // C5 filter strategy: scope validation in onBegin flagged this test
439
+ // as out-of-scope for the configured scenarioId/testSetId. The test
440
+ // still executed (the customer has their local report), but its
441
+ // verdict must not land in the wrong run.
442
+ if (this.scopeFilterEnabled && uuid && this.inScopeUuids && !this.inScopeUuids.has(uuid)) {
443
+ this.droppedOutOfScopeCount++;
444
+ logger.debug('Dropping out-of-scope result (C5 filter strategy)', { uuid });
445
+ return;
446
+ }
447
+
375
448
  const project = test.parent?.project?.() || test.parent?.project;
376
449
  const deviceInfo = DeviceOsDetector.getDeviceInfo(project, null);
377
450
  const projectKey = `${deviceInfo.device}-${deviceInfo.os}`;
@@ -405,7 +478,15 @@ class AppliqationReporter {
405
478
  return;
406
479
  }
407
480
 
408
- // Duplicate detection
481
+ // Duplicate detection — user-facing warning only, NOT a data-integrity
482
+ // guard. The backend's upsert key is (run_id, uuid, parent_uuid,
483
+ // browser), so re-submitting the same UUID for the same browser is
484
+ // idempotent at the DB level (see appq PR #532's automation.attempt
485
+ // $inc semantics). This tracking exists purely so the end-of-run
486
+ // summary can point out authoring mistakes (two tests annotated with
487
+ // the same UUID), and it's per-reporter-instance — cross-shard
488
+ // duplicates from `--shard` are not caught here, and don't need to
489
+ // be, because the backend's upsert still handles them correctly.
409
490
  const trackingKey = `${runInfo.runId}:${deviceInfo.browser}`;
410
491
  const submittedUuids = this.submittedUuidsByRun.get(trackingKey) || new Map();
411
492
 
@@ -462,9 +543,9 @@ class AppliqationReporter {
462
543
 
463
544
  this.trackResult(runInfo.runId, testResult);
464
545
 
465
- if (testResult.status === 'Pass') this.passedTests++;
466
- else if (testResult.status === 'Fail') this.failedTests++;
467
- else if (testResult.status === 'Skipped') this.skippedTests++;
546
+ if (testResult.status === 'passed') this.passedTests++;
547
+ else if (testResult.status === 'failed') this.failedTests++;
548
+ else if (testResult.status === 'skipped') this.skippedTests++;
468
549
 
469
550
  if (!this.config.batchSubmit) {
470
551
  await this.client.submitResult(runInfo.runId, testResult);
@@ -565,6 +646,14 @@ class AppliqationReporter {
565
646
  logger.info('Test run complete.');
566
647
 
567
648
  try {
649
+ // See onTestEnd()'s doc comment — Playwright doesn't reliably await
650
+ // per-test reporter hooks, so results/orphans tracked by still-
651
+ // pending onTestEnd() calls (e.g. waiting on a slow run creation)
652
+ // would otherwise be silently missing from what we're about to read.
653
+ if (this.pendingTestEndPromises.length > 0) {
654
+ await Promise.allSettled(this.pendingTestEndPromises);
655
+ }
656
+
568
657
  const orphanDeletedRuns = await this.handleOrphanOnlyRuns();
569
658
 
570
659
  if (this.appqEnabled) {
@@ -660,20 +749,33 @@ class AppliqationReporter {
660
749
 
661
750
  /** @private */
662
751
  mapStatus(status) {
752
+ // Playwright's `interrupted` means the entire test run was aborted
753
+ // (Ctrl+C, CI timeout, OOM). Mapping it to 'skipped' would mask
754
+ // failures and inflate the skipped counter — surface as failed instead
755
+ // so the run summary honestly reflects an aborted execution.
756
+ //
757
+ // Values MUST match PayloadBuilder.validateAutomationResultPayload's
758
+ // ALLOWED_STATUSES ({passed, failed, skipped}, lowercase past-tense —
759
+ // mirrors the backend's InputValidator). This reporter's own mapped
760
+ // value is what ultimately reaches that validator (via trackResult →
761
+ // submitBatch → PayloadBuilder.buildResultPayload → normalizeStatus,
762
+ // which passes an already-recognized lowercase value straight through),
763
+ // so drifting from that vocabulary here silently fails every submission.
663
764
  const statusMap = {
664
- 'passed': 'Pass',
665
- 'failed': 'Fail',
666
- 'timedOut': 'Fail',
667
- 'skipped': 'Skipped',
668
- 'interrupted': 'Skipped'
765
+ 'passed': 'passed',
766
+ 'failed': 'failed',
767
+ 'timedOut': 'failed',
768
+ 'skipped': 'skipped',
769
+ 'interrupted': 'failed'
669
770
  };
670
- return statusMap[status] || 'Fail';
771
+ return statusMap[status] || 'failed';
671
772
  }
672
773
 
673
774
  /** @private */
674
775
  buildComment(test, result) {
675
776
  const parts = [];
676
777
  if (result.duration) parts.push(`Duration: ${(result.duration / 1000).toFixed(2)}s`);
778
+ if (result.status === 'interrupted') parts.push('Test run interrupted');
677
779
  if (result.error) {
678
780
  const errorMsg = result.error.message || result.error.toString();
679
781
  parts.push(`Error: ${errorMsg.substring(0, 500)}`);
@@ -868,6 +970,82 @@ class AppliqationReporter {
868
970
  return Array.from(projectNames);
869
971
  }
870
972
 
973
+ /**
974
+ * Run C5 scope validation against the Playwright suite. Mutates
975
+ * `this.config` if the user chooses the 'adhoc' strategy (clears
976
+ * scenarioId/testSetId so subsequent run creation is ad-hoc).
977
+ * Sets `this.scopeFilterEnabled` and `this.inScopeUuids` for the
978
+ * 'filter' strategy so `onTestEnd` can drop out-of-scope results.
979
+ *
980
+ * @private
981
+ * @param {Object} suite - Playwright Suite from onBegin
982
+ * @returns {Promise<{cancelled: boolean}>}
983
+ */
984
+ async _runScopeValidation(suite, preExtractedUuids) {
985
+ const validator = new ScopeValidator({
986
+ scenarioId: this.config.scenarioId,
987
+ testSetId: this.config.testSetId,
988
+ strategy: this.config.onScopeMismatch,
989
+ fetchTestSetScope: this._fetchTestSetScope.bind(this)
990
+ });
991
+
992
+ if (!validator.isScoped()) {
993
+ return { cancelled: false };
994
+ }
995
+
996
+ const uuids = preExtractedUuids || validator.extractUuidsFromSuite(suite);
997
+ const validation = await validator.validate(uuids);
998
+ if (!validation.hasMismatch) {
999
+ return { cancelled: false };
1000
+ }
1001
+
1002
+ const outcome = await validator.resolve(validation);
1003
+ // Surface the resolution message at WARN so it's visible at default
1004
+ // log level — this is operational signal, not noise.
1005
+ logger.warn(outcome.message);
1006
+
1007
+ if (outcome.action === ScopeValidator.STRATEGY.CANCEL) {
1008
+ // Set non-zero exit code so CI fails. Don't throw — we still want
1009
+ // Playwright to finish executing tests locally for the user's logs.
1010
+ process.exitCode = 1;
1011
+ return { cancelled: true };
1012
+ }
1013
+
1014
+ if (outcome.overrideToAdhoc) {
1015
+ // Drop the configured scope so createRun produces an ad-hoc run.
1016
+ delete this.config.scenarioId;
1017
+ delete this.config.testSetId;
1018
+ return { cancelled: false };
1019
+ }
1020
+
1021
+ if (outcome.action === ScopeValidator.STRATEGY.FILTER) {
1022
+ this.scopeFilterEnabled = true;
1023
+ this.inScopeUuids = new Set(outcome.submitUuids);
1024
+ }
1025
+
1026
+ return { cancelled: false };
1027
+ }
1028
+
1029
+ /**
1030
+ * Resolve a testSet's UUID membership. Requires a backend endpoint
1031
+ * (GET /api/automation/testset/{id}/scope) that returns the UUID list.
1032
+ * Until that endpoint ships, this throws — ScopeValidator catches and
1033
+ * gracefully degrades to skip testset validation with a warning.
1034
+ *
1035
+ * @private
1036
+ * @param {number} testSetId
1037
+ * @returns {Promise<Set<string>>}
1038
+ */
1039
+ async _fetchTestSetScope(testSetId) {
1040
+ if (!this.client || typeof this.client.fetchTestSetScope !== 'function') {
1041
+ throw new Error(
1042
+ 'TestSet scope endpoint not yet available in AppliqationClient — '
1043
+ + 'testset scope validation will be skipped'
1044
+ );
1045
+ }
1046
+ return this.client.fetchTestSetScope(testSetId);
1047
+ }
1048
+
871
1049
  /**
872
1050
  * Auto-detect browser versions
873
1051
  * @private
@@ -44,10 +44,29 @@ class OrphanTestService {
44
44
 
45
45
  return response;
46
46
  } catch (error) {
47
- logger.error('Failed to log orphan tests', {
47
+ // S5 as of appq_automation_api.routing.yml, /api/automation/
48
+ // orphan-tests is not a registered route. Backend implementation
49
+ // is pending. Until it lands, a 404 here is expected — demote to
50
+ // debug so it doesn't spam customer CI logs. Orphans are still
51
+ // tracked locally and shown in the reporter's end-of-run summary.
52
+ const status = error.status
53
+ || error.response?.status
54
+ || error.details?.status
55
+ || null;
56
+
57
+ if (status === 404) {
58
+ logger.debug('Orphan tests endpoint not available on backend; skipping network log', {
59
+ runId,
60
+ count: orphanTests.length
61
+ });
62
+ return { success: false, notImplemented: true, count: orphanTests.length };
63
+ }
64
+
65
+ logger.warn('Failed to log orphan tests', {
48
66
  error: error.message,
49
67
  runId,
50
- count: orphanTests.length
68
+ count: orphanTests.length,
69
+ status
51
70
  });
52
71
  throw new Error(`Failed to log orphan tests: ${error.message}`);
53
72
  }
@@ -1,5 +1,6 @@
1
1
  const logger = require('../utils/logger');
2
2
  const { normalizeBrowser } = require('../utils/RunDataNormalizer');
3
+ const PayloadBuilder = require('../utils/PayloadBuilder');
3
4
 
4
5
  class ResultService {
5
6
  constructor(httpClient, taggingService = null, config = { options: {} }) {
@@ -27,6 +28,16 @@ class ResultService {
27
28
 
28
29
  const payload = this.buildAutomationResultPayload(runId, result, runMetadata);
29
30
 
31
+ // S8 — final shape check before the wire. Catches schema drift
32
+ // between SDK assembly and backend expectations at the client
33
+ // instead of failing at the server with a generic 400.
34
+ const validation = PayloadBuilder.validateAutomationResultPayload(payload);
35
+ if (!validation.valid) {
36
+ throw new Error(
37
+ `Result payload failed pre-submission validation: ${validation.errors.join('; ')}`
38
+ );
39
+ }
40
+
30
41
  logger.debug('Submitting single result', {
31
42
  runId,
32
43
  uuid: result.uuid,
@@ -36,16 +47,12 @@ class ResultService {
36
47
  // Use automation endpoint to enable project validation
37
48
  const response = await this.http.post('/api/automation/result/submit', payload);
38
49
 
39
- // DEBUG: Show response with context
40
- console.log('\n🔍 ========== SUBMIT RESULT RESPONSE ==========');
41
- console.log('UUID:', result.uuid);
42
- if (response.data && response.data.debug_context) {
43
- console.log('Project ID (from API):', response.data.debug_context.project_id);
44
- console.log('User ID (from API):', response.data.debug_context.uid);
45
- }
46
- console.log('Status:', response.status);
47
- console.log('Success:', response.success);
48
- console.log('===========================================\n');
50
+ logger.debug('Result submitted', {
51
+ uuid: result.uuid,
52
+ status: response.status,
53
+ success: response.success,
54
+ projectId: response.data?.debug_context?.project_id
55
+ });
49
56
 
50
57
  // Check if request was successful
51
58
  if (!response.success) {
@@ -132,30 +139,51 @@ class ResultService {
132
139
  results: batch.map(r => this.buildAutomationResultPayload(runId, r, runMetadata))
133
140
  };
134
141
 
135
- logger.debug('Batch payload', { payload: JSON.stringify(payload, null, 2) });
136
-
137
- // Show UUIDs being submitted (test execution status shown, NOT backend validation status)
138
- console.log('\n📤 Submitting batch with UUIDs:');
142
+ // S8 validate every result in the batch before the wire.
143
+ // A malformed entry is filtered out and moved to failures rather
144
+ // than tainting the whole batch (backend would 400 the whole
145
+ // batch otherwise). Track per-result so downstream reporting
146
+ // still knows which UUIDs never made it.
147
+ const validResults = [];
139
148
  payload.results.forEach((r, idx) => {
140
- console.log(` ${idx + 1}. ${r.test_case_uuid}`);
149
+ const v = PayloadBuilder.validateAutomationResultPayload(r);
150
+ if (v.valid) {
151
+ validResults.push(r);
152
+ } else {
153
+ logger.warn('Skipping malformed result at client-side validation', {
154
+ uuid: r.test_case_uuid,
155
+ errors: v.errors
156
+ });
157
+ failures.push(batch[idx]);
158
+ }
159
+ });
160
+ if (validResults.length === 0) {
161
+ logger.warn('Every result in batch failed pre-submission validation; skipping HTTP call', {
162
+ runId, batchIndex: i + 1
163
+ });
164
+ continue;
165
+ }
166
+ payload.results = validResults;
167
+
168
+ logger.debug('Submitting batch', {
169
+ runId,
170
+ batchIndex: i + 1,
171
+ totalBatches: batches.length,
172
+ uuids: payload.results.map(r => r.test_case_uuid)
141
173
  });
142
174
 
143
175
  try {
144
176
  const response = await this.http.post('/api/automation/result/batch', payload);
145
177
 
146
- // DEBUG: Show batch response with context
147
- console.log('\n🔍 ========== BATCH SUBMIT RESPONSE ==========');
148
- console.log('Run ID:', runId);
149
- console.log('Batch:', `${i + 1}/${batches.length}`);
150
- console.log('Results in batch:', batch.length);
151
- if (response.data && response.data.debug_context) {
152
- console.log('Project ID (from API):', response.data.debug_context.project_id);
153
- console.log('User ID (from API):', response.data.debug_context.uid);
154
- } else {
155
- console.log('⚠️ No debug_context in response');
156
- }
157
- console.log('Status:', response.status);
158
- console.log('Success:', response.success);
178
+ logger.debug('Batch submitted', {
179
+ runId,
180
+ batchIndex: i + 1,
181
+ totalBatches: batches.length,
182
+ resultsInBatch: batch.length,
183
+ status: response.status,
184
+ success: response.success,
185
+ projectId: response.data?.debug_context?.project_id
186
+ });
159
187
 
160
188
  // Check for validation failures - response has nested data object
161
189
  const failedResults = response.data?.data?.results?.failed || response.data?.results?.failed || response.data?.failed || [];
@@ -163,24 +191,20 @@ class ResultService {
163
191
  const submittedCount = typeof submittedResults === 'number' ? submittedResults : (Array.isArray(submittedResults) ? submittedResults.length : 0);
164
192
 
165
193
  if (failedResults.length > 0) {
166
- console.log('\n❌ ========== VALIDATION FAILURES ==========');
167
- console.log(` ✅ ACCEPTED: ${submittedCount} result(s)`);
168
- console.log(` REJECTED: ${failedResults.length} result(s)\n`);
169
- failedResults.forEach((fail, idx) => {
170
- const uuid = fail.test_case_uuid || fail.uuid;
171
- const error = fail.error || fail.message;
172
- const code = fail.code;
173
-
174
- console.log(` ${idx + 1}. UUID: ${uuid}`);
175
- console.log(` ❌ Reason: ${error}`);
176
- if (code) {
177
- console.log(` 🚫 HTTP Code: ${code}`);
178
- }
179
- console.log('');
194
+ // Validation failures are operational signal — surface at WARN, not DEBUG.
195
+ // Logger sanitization redacts any sensitive fields in the payload.
196
+ logger.warn(`Backend rejected ${failedResults.length} of ${batch.length} results`, {
197
+ runId,
198
+ batchIndex: i + 1,
199
+ accepted: submittedCount,
200
+ rejected: failedResults.length,
201
+ rejections: failedResults.map(f => ({
202
+ uuid: f.test_case_uuid || f.uuid,
203
+ reason: f.error || f.message,
204
+ code: f.code
205
+ }))
180
206
  });
181
- console.log('===========================================');
182
207
  }
183
- console.log('============================================\n');
184
208
 
185
209
  if (!response.success) {
186
210
  throw new Error(response.error || 'Batch submission failed');
@@ -348,12 +372,19 @@ class ResultService {
348
372
  failed: 'failed',
349
373
  skipped: 'skipped',
350
374
  timedOut: 'failed',
351
- interrupted: 'skipped'
375
+ // Aborted run, not a skip — see AppliqationReporter.mapStatus.
376
+ interrupted: 'failed'
352
377
  };
353
378
 
354
379
  return {
355
380
  run_id: runId || result.runId,
356
381
  test_case_uuid: result.uuid,
382
+ // Routes the backend to submitAutomationResult() — writes to the
383
+ // automation.* subdocument only, never overwrites a human verdict on
384
+ // top-level status/comment/verdict_source. Post PR #532 the backend
385
+ // routes API-key traffic to this path unconditionally; sending the
386
+ // field explicitly makes intent unambiguous and is forward-safe.
387
+ source: 'automation',
357
388
  status: statusMap[result.status] || result.status,
358
389
  duration: result.duration || 0,
359
390
  error_message: result.error || result.comment || '',
@@ -53,9 +53,17 @@ class RunMatrixService {
53
53
  const browsers = options.browsers || ['Chrome'];
54
54
  const normalizedBrowsers = browsers.map(browser => normalizeBrowser(browser));
55
55
 
56
+ // S2 — backend (AutomationApiController::createRun) uppercases env
57
+ // names on storage and matches case-insensitively on validation.
58
+ // Uppercase locally so the run-creation log and the stored doc
59
+ // agree, and so any downstream filter that matches on the case the
60
+ // SDK sent doesn't quietly mismatch.
61
+ const envRaw = options.environment || 'Local';
62
+ const environment = typeof envRaw === 'string' ? envRaw.toUpperCase() : envRaw;
63
+
56
64
  const payload = {
57
65
  project_key: projectKeyValue,
58
- environment: options.environment || 'Local',
66
+ environment,
59
67
  browsers: normalizedBrowsers,
60
68
  device: normalizeDevice(options.device || this.detectDevice()),
61
69
  os: normalizeOS(options.os || this.detectOS()),
@@ -75,6 +83,18 @@ class RunMatrixService {
75
83
  payload.type = options.type || 'automation';
76
84
  }
77
85
 
86
+ // C4 / E3 — pre-populate data[] with the UUIDs about to be executed.
87
+ // Backend stores this on the run document so the UI grid knows which
88
+ // TCs belong to the run before any result lands. Without it, ad-hoc
89
+ // runs show 0 expected tests and the orphan detector flags every
90
+ // valid test as orphan.
91
+ //
92
+ // PR #532 (appq) changed data[] from objects ({uuid: '...'}) to
93
+ // plain strings — match that format.
94
+ if (Array.isArray(options.uuids) && options.uuids.length > 0) {
95
+ payload.data = options.uuids.map((u) => String(u));
96
+ }
97
+
78
98
  logger.info('Creating run matrix...', {
79
99
  type: payload.type,
80
100
  id: payload.scenario_id || payload.testset_id,