@appliqation/automation-sdk 2.5.1 → 2.8.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.
@@ -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,
@@ -0,0 +1,314 @@
1
+ const readline = require('readline');
2
+ const UuidValidator = require('../utils/UuidValidator');
3
+ const UuidExtractor = require('../reporters/playwright/helpers/UuidExtractor');
4
+ const logger = require('../utils/logger');
5
+
6
+ const STRATEGY = {
7
+ CANCEL: 'cancel',
8
+ ADHOC: 'adhoc',
9
+ FILTER: 'filter'
10
+ };
11
+
12
+ const VALID_STRATEGIES = Object.values(STRATEGY);
13
+
14
+ /**
15
+ * Validates that the UUIDs about to be executed match the user's configured
16
+ * scenarioId or testSetId scope, and applies a resolution strategy when they
17
+ * don't.
18
+ *
19
+ * Closes finding C5 in the SDK audit — without this, the SDK silently
20
+ * submits cross-scenario results to a single-scenario run, corrupting the
21
+ * Appliqation data store.
22
+ *
23
+ * Three resolution strategies, selected via `config.onScopeMismatch`:
24
+ * 'cancel' (default in CI) — fail fast, exit 1, submit nothing
25
+ * 'adhoc' — override config, create an ad-hoc run
26
+ * 'filter' — drop out-of-scope results, submit the rest
27
+ *
28
+ * In a TTY (no `CI=true` and stdin is a TTY), the user is prompted
29
+ * interactively before any test runs. In CI, the configured strategy is
30
+ * used; if unset, 'cancel' is the fail-safe default.
31
+ */
32
+ class ScopeValidator {
33
+ /**
34
+ * @param {Object} options
35
+ * @param {number} [options.scenarioId]
36
+ * @param {number} [options.testSetId]
37
+ * @param {'cancel'|'adhoc'|'filter'} [options.strategy]
38
+ * @param {Function} [options.fetchTestSetScope] async (testSetId) -> Set<uuid>
39
+ */
40
+ constructor(options = {}) {
41
+ this.scenarioId = options.scenarioId || null;
42
+ this.testSetId = options.testSetId || null;
43
+ this.strategy = this._resolveStrategy(options.strategy);
44
+ this.fetchTestSetScope = options.fetchTestSetScope || null;
45
+ }
46
+
47
+ _resolveStrategy(explicit) {
48
+ const envValue = process.env.APPLIQATION_ON_SCOPE_MISMATCH;
49
+ const raw = (explicit || envValue || STRATEGY.CANCEL).toLowerCase();
50
+ if (!VALID_STRATEGIES.includes(raw)) {
51
+ logger.warn(`Unknown onScopeMismatch strategy "${raw}", falling back to "cancel"`);
52
+ return STRATEGY.CANCEL;
53
+ }
54
+ return raw;
55
+ }
56
+
57
+ /**
58
+ * @returns {boolean} True if scenarioId or testSetId is set — i.e., the
59
+ * user has declared a scope and we need to validate against it.
60
+ */
61
+ isScoped() {
62
+ return Boolean(this.scenarioId || this.testSetId);
63
+ }
64
+
65
+ /**
66
+ * Extract every Appliqation UUID from a Playwright suite tree.
67
+ *
68
+ * @param {Object} suite - Playwright Suite from onBegin
69
+ * @returns {string[]} Unique UUIDs, in test-discovery order
70
+ */
71
+ extractUuidsFromSuite(suite) {
72
+ if (!suite || typeof suite.allTests !== 'function') return [];
73
+
74
+ const seen = new Set();
75
+ const ordered = [];
76
+ for (const test of suite.allTests()) {
77
+ const uuid = UuidExtractor.extractFromAnnotations(test.annotations || [])
78
+ || UuidExtractor.extractFromTest(test);
79
+ if (uuid && !seen.has(uuid)) {
80
+ seen.add(uuid);
81
+ ordered.push(uuid);
82
+ }
83
+ }
84
+ return ordered;
85
+ }
86
+
87
+ /**
88
+ * Validate the set of UUIDs against the configured scope.
89
+ *
90
+ * @param {string[]} uuids
91
+ * @returns {Promise<{
92
+ * hasMismatch: boolean,
93
+ * inScope: string[],
94
+ * outOfScope: Array<{uuid: string, actualScenarioId?: number}>,
95
+ * reason: 'scenario' | 'testset' | null
96
+ * }>}
97
+ */
98
+ async validate(uuids) {
99
+ if (!this.isScoped()) {
100
+ return { hasMismatch: false, inScope: uuids, outOfScope: [], reason: null };
101
+ }
102
+ if (!Array.isArray(uuids) || uuids.length === 0) {
103
+ return { hasMismatch: false, inScope: [], outOfScope: [], reason: null };
104
+ }
105
+
106
+ if (this.scenarioId) {
107
+ return this._validateScenario(uuids);
108
+ }
109
+ return this._validateTestSet(uuids);
110
+ }
111
+
112
+ _validateScenario(uuids) {
113
+ const inScope = [];
114
+ const outOfScope = [];
115
+
116
+ for (const uuid of uuids) {
117
+ const nid = UuidValidator.extractNid(uuid);
118
+ if (nid === null) {
119
+ // Malformed UUID — let the regular submission path reject it.
120
+ // Don't fail the scope check on bad data we didn't author.
121
+ inScope.push(uuid);
122
+ continue;
123
+ }
124
+ if (nid === Number(this.scenarioId)) {
125
+ inScope.push(uuid);
126
+ } else {
127
+ outOfScope.push({ uuid, actualScenarioId: nid });
128
+ }
129
+ }
130
+
131
+ return {
132
+ hasMismatch: outOfScope.length > 0,
133
+ inScope,
134
+ outOfScope,
135
+ reason: 'scenario'
136
+ };
137
+ }
138
+
139
+ async _validateTestSet(uuids) {
140
+ if (!this.fetchTestSetScope) {
141
+ // No way to fetch testset membership — fail open with a warning
142
+ // rather than blocking the run on a missing dependency.
143
+ logger.warn(
144
+ 'testSetId is set but no testset-scope fetcher was provided; '
145
+ + 'skipping scope validation. Pass `fetchTestSetScope` to enable.'
146
+ );
147
+ return { hasMismatch: false, inScope: uuids, outOfScope: [], reason: null };
148
+ }
149
+
150
+ let scope;
151
+ try {
152
+ scope = await this.fetchTestSetScope(this.testSetId);
153
+ } catch (error) {
154
+ logger.warn('Failed to fetch testset scope; skipping validation', {
155
+ testSetId: this.testSetId,
156
+ error: error.message
157
+ });
158
+ return { hasMismatch: false, inScope: uuids, outOfScope: [], reason: null };
159
+ }
160
+
161
+ const scopeSet = scope instanceof Set ? scope : new Set(scope || []);
162
+ const inScope = [];
163
+ const outOfScope = [];
164
+
165
+ for (const uuid of uuids) {
166
+ if (scopeSet.has(uuid)) {
167
+ inScope.push(uuid);
168
+ } else {
169
+ outOfScope.push({ uuid });
170
+ }
171
+ }
172
+
173
+ return {
174
+ hasMismatch: outOfScope.length > 0,
175
+ inScope,
176
+ outOfScope,
177
+ reason: 'testset'
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Resolve a scope mismatch by applying the chosen strategy. In a TTY,
183
+ * the user is prompted interactively before any strategy is applied.
184
+ *
185
+ * @param {Object} validation - result of `validate()`
186
+ * @returns {Promise<{
187
+ * action: 'cancel' | 'adhoc' | 'filter',
188
+ * submitUuids: string[],
189
+ * droppedUuids: string[],
190
+ * overrideToAdhoc: boolean,
191
+ * message: string
192
+ * }>}
193
+ */
194
+ async resolve(validation) {
195
+ if (!validation.hasMismatch) {
196
+ return {
197
+ action: 'pass',
198
+ submitUuids: validation.inScope,
199
+ droppedUuids: [],
200
+ overrideToAdhoc: false,
201
+ message: 'Scope check passed.'
202
+ };
203
+ }
204
+
205
+ const strategy = this._isInteractive()
206
+ ? await this._promptInteractive(validation)
207
+ : this.strategy;
208
+
209
+ return this._applyStrategy(strategy, validation);
210
+ }
211
+
212
+ _isInteractive() {
213
+ if (process.env.CI === 'true' || process.env.CI === '1') return false;
214
+ return Boolean(process.stdin && process.stdin.isTTY);
215
+ }
216
+
217
+ async _promptInteractive(validation) {
218
+ const summary = this._formatMismatchSummary(validation);
219
+ // Use process.stdout (not logger) because this is a synchronous user
220
+ // prompt that must appear regardless of log level.
221
+ process.stdout.write('\n');
222
+ process.stdout.write(summary);
223
+ process.stdout.write('\n');
224
+ process.stdout.write('Choose how to proceed:\n');
225
+ process.stdout.write(' [1] cancel — fail fast, submit nothing (safest)\n');
226
+ process.stdout.write(' [2] adhoc — create an ad-hoc run and submit everything\n');
227
+ process.stdout.write(' [3] filter — submit only in-scope results, drop the rest\n');
228
+
229
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
230
+ const ask = (q) => new Promise((res) => rl.question(q, (a) => res(a)));
231
+ try {
232
+ const answer = (await ask('Selection [1/2/3]: ')).trim();
233
+ const map = { '1': STRATEGY.CANCEL, '2': STRATEGY.ADHOC, '3': STRATEGY.FILTER };
234
+ return map[answer] || STRATEGY.CANCEL;
235
+ } finally {
236
+ rl.close();
237
+ }
238
+ }
239
+
240
+ _applyStrategy(strategy, validation) {
241
+ const droppedUuids = validation.outOfScope.map((o) => o.uuid);
242
+ const summary = this._formatMismatchSummary(validation);
243
+
244
+ if (strategy === STRATEGY.CANCEL) {
245
+ return {
246
+ action: STRATEGY.CANCEL,
247
+ submitUuids: [],
248
+ droppedUuids: [...validation.inScope, ...droppedUuids],
249
+ overrideToAdhoc: false,
250
+ message:
251
+ `${summary}\n`
252
+ + 'Strategy: cancel. No results will be submitted to Appliqation.\n'
253
+ + 'Set onScopeMismatch (or APPLIQATION_ON_SCOPE_MISMATCH) to '
254
+ + '"adhoc" or "filter" to change this behaviour.'
255
+ };
256
+ }
257
+
258
+ if (strategy === STRATEGY.ADHOC) {
259
+ return {
260
+ action: STRATEGY.ADHOC,
261
+ submitUuids: [...validation.inScope, ...droppedUuids],
262
+ droppedUuids: [],
263
+ overrideToAdhoc: true,
264
+ message:
265
+ `${summary}\n`
266
+ + 'Strategy: adhoc. The configured scenarioId/testSetId is being '
267
+ + 'overridden — an ad-hoc run will be created instead and all '
268
+ + 'results will be submitted to it.'
269
+ };
270
+ }
271
+
272
+ // FILTER
273
+ return {
274
+ action: STRATEGY.FILTER,
275
+ submitUuids: validation.inScope,
276
+ droppedUuids,
277
+ overrideToAdhoc: false,
278
+ message:
279
+ `${summary}\n`
280
+ + `Strategy: filter. ${validation.inScope.length} in-scope result(s) `
281
+ + `will be submitted; ${droppedUuids.length} out-of-scope result(s) `
282
+ + 'will execute locally but their verdicts will NOT be uploaded.'
283
+ };
284
+ }
285
+
286
+ _formatMismatchSummary(validation) {
287
+ const target = validation.reason === 'scenario'
288
+ ? `scenarioId ${this.scenarioId}`
289
+ : `testSetId ${this.testSetId}`;
290
+
291
+ const examples = validation.outOfScope.slice(0, 5).map((o) => {
292
+ if (o.actualScenarioId !== undefined) {
293
+ return ` - ${o.uuid} (belongs to scenario ${o.actualScenarioId})`;
294
+ }
295
+ return ` - ${o.uuid} (not in testset)`;
296
+ }).join('\n');
297
+
298
+ const more = validation.outOfScope.length > 5
299
+ ? `\n … and ${validation.outOfScope.length - 5} more`
300
+ : '';
301
+
302
+ return (
303
+ `[Appliqation] Scope mismatch detected.\n`
304
+ + `Configured: ${target}\n`
305
+ + `Out-of-scope test(s): ${validation.outOfScope.length} of ${validation.inScope.length + validation.outOfScope.length}\n`
306
+ + examples
307
+ + more
308
+ );
309
+ }
310
+ }
311
+
312
+ ScopeValidator.STRATEGY = STRATEGY;
313
+
314
+ module.exports = ScopeValidator;
@@ -1,4 +1,5 @@
1
1
  const logger = require('../utils/logger');
2
+ const { AUTO_TAG_NAME } = require('../constants');
2
3
 
3
4
  /**
4
5
  * Service for auto-tagging test cases after successful runs
@@ -8,12 +9,11 @@ class TaggingService {
8
9
  this.http = httpClient;
9
10
  this.config = config;
10
11
 
11
- // Configuration resolution: Runtime options > Env vars > Defaults
12
+ // Whether to auto-tag is configurable; the tag name itself is not —
13
+ // see AUTO_TAG_NAME's doc comment.
12
14
  this.enabled = this.config?.options?.autoTag !== false &&
13
15
  process.env.APPLIQATION_AUTO_TAG_ENABLED !== 'false';
14
- this.tagName = this.config?.options?.autoTagName ||
15
- process.env.APPLIQATION_AUTO_TAG_NAME ||
16
- 'Appq_automated';
16
+ this.tagName = AUTO_TAG_NAME;
17
17
  this.batchSize = this.config?.options?.autoTagBatchSize || 50;
18
18
  this.retries = this.config?.options?.autoTagRetries || 2;
19
19
  }
@@ -42,7 +42,7 @@ class TaggingService {
42
42
  const unique = Array.from(new Set(uuids.filter(Boolean)));
43
43
 
44
44
  try {
45
- // Build query string: uuids=123-xxx,124-yyy&tag=Appq_automated
45
+ // Build query string: uuids=123-xxx,124-yyy&tag=Appq_Auto
46
46
  const uuidsParam = unique.join(',');
47
47
  const url = `/api/automation/testcases/tags/check?uuids=${encodeURIComponent(uuidsParam)}&tag=${encodeURIComponent(tag)}`;
48
48
  const response = await this.http.get(url);