@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.
- package/README.md +330 -1169
- package/package.json +13 -34
- package/src/AppliqationClient.js +125 -25
- package/src/cli/auth-setup.js +308 -0
- package/src/constants.js +21 -1
- package/src/core/AuthManager.js +5 -0
- package/src/index.d.ts +159 -6
- package/src/login/index.d.ts +77 -0
- package/src/login/index.js +49 -0
- package/src/playwright/fixture.js +4 -1
- package/src/reporters/playwright/AppliqationReporter.js +159 -11
- package/src/services/OrphanTestService.js +21 -2
- package/src/services/ResultService.js +76 -45
- package/src/services/RunMatrixService.js +21 -1
- package/src/services/ScopeValidator.js +314 -0
- package/src/services/TaggingService.js +5 -5
- package/src/utils/PayloadBuilder.js +112 -21
- package/src/utils/index.js +12 -0
- package/src/utils/logger.js +59 -5
- package/src/utils/setupAuth.js +99 -0
- package/src/reporters/cypress/CypressReporter.js +0 -434
- package/src/reporters/cypress/UuidExtractor.js +0 -139
- package/src/reporters/cypress/index.js +0 -30
- package/src/reporters/jest/JestReporter.js +0 -408
- package/src/reporters/jest/UuidExtractor.js +0 -174
- package/src/reporters/jest/index.js +0 -28
|
@@ -114,36 +114,19 @@ class PayloadBuilder {
|
|
|
114
114
|
static normalizeStatus(status) {
|
|
115
115
|
if (!status) return 'unknown';
|
|
116
116
|
|
|
117
|
+
// Playwright-only status map. Cypress/Jest/generic variants were
|
|
118
|
+
// removed alongside the Cypress and Jest reporters — D1/D2/D3.
|
|
117
119
|
const statusMap = {
|
|
118
|
-
// Standard statuses
|
|
119
120
|
'passed': 'passed',
|
|
120
121
|
'failed': 'failed',
|
|
121
122
|
'skipped': 'skipped',
|
|
122
123
|
'pending': 'pending',
|
|
123
|
-
|
|
124
|
-
// Playwright variants
|
|
125
124
|
'expected': 'passed',
|
|
126
125
|
'unexpected': 'failed',
|
|
127
126
|
'flaky': 'flaky',
|
|
128
127
|
'timedOut': 'failed',
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// Cypress variants
|
|
132
|
-
'pass': 'passed',
|
|
133
|
-
'fail': 'failed',
|
|
134
|
-
|
|
135
|
-
// Jest variants
|
|
136
|
-
'success': 'passed',
|
|
137
|
-
'error': 'failed',
|
|
138
|
-
'disabled': 'skipped',
|
|
139
|
-
|
|
140
|
-
// Generic variants
|
|
141
|
-
'ok': 'passed',
|
|
142
|
-
'success': 'passed',
|
|
143
|
-
'failure': 'failed',
|
|
144
|
-
'error': 'failed',
|
|
145
|
-
'skip': 'skipped',
|
|
146
|
-
'todo': 'pending'
|
|
128
|
+
// Aborted run, not a skip — see AppliqationReporter.mapStatus.
|
|
129
|
+
'interrupted': 'failed'
|
|
147
130
|
};
|
|
148
131
|
|
|
149
132
|
const normalized = statusMap[status.toLowerCase()];
|
|
@@ -212,6 +195,114 @@ class PayloadBuilder {
|
|
|
212
195
|
};
|
|
213
196
|
}
|
|
214
197
|
|
|
198
|
+
/**
|
|
199
|
+
* S8 — Pre-submission payload validation for the automation result
|
|
200
|
+
* endpoint. Runs AFTER buildAutomationResultPayload has assembled the
|
|
201
|
+
* final wire payload, immediately before the HTTP POST. Enforces the
|
|
202
|
+
* exact shape the backend's InputValidator + AutomationResultManager
|
|
203
|
+
* accept, so shape drift (wrong field name, missing required field,
|
|
204
|
+
* bad type) is caught at the client instead of failing at the server
|
|
205
|
+
* with a generic 400 mid-run.
|
|
206
|
+
*
|
|
207
|
+
* Defence-in-depth on top of H2 (which validates user-supplied CONFIG
|
|
208
|
+
* at startup) — S8 validates the assembled OUTGOING payload.
|
|
209
|
+
*
|
|
210
|
+
* Backend constraints (mirror of appq_mongo InputValidator +
|
|
211
|
+
* AutomationApiController::submitResultBatch):
|
|
212
|
+
* run_id required, matches /^[a-zA-Z0-9_:-]+$/ max 100
|
|
213
|
+
* test_case_uuid required, matches UuidValidator's regex
|
|
214
|
+
* status required, one of {passed, failed, skipped}
|
|
215
|
+
* source 'automation' when SDK-submitted (post C1)
|
|
216
|
+
* browser required, matches /^[a-zA-Z0-9 .\-_(),\/]+$/ max 255
|
|
217
|
+
* environment matches /^[a-zA-Z0-9._-]+$/ max 255
|
|
218
|
+
* duration integer >= 0 when present
|
|
219
|
+
* timestamp positive integer when present
|
|
220
|
+
*
|
|
221
|
+
* @param {Object} payload - Fully assembled result payload
|
|
222
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
223
|
+
*/
|
|
224
|
+
static validateAutomationResultPayload(payload) {
|
|
225
|
+
const errors = [];
|
|
226
|
+
|
|
227
|
+
if (!payload || typeof payload !== 'object') {
|
|
228
|
+
return { valid: false, errors: ['payload must be an object'] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// run_id
|
|
232
|
+
if (typeof payload.run_id !== 'string' || payload.run_id.length === 0) {
|
|
233
|
+
errors.push('run_id must be a non-empty string');
|
|
234
|
+
} else if (payload.run_id.length > 100) {
|
|
235
|
+
errors.push(`run_id exceeds 100 chars (got ${payload.run_id.length})`);
|
|
236
|
+
} else if (!/^[a-zA-Z0-9_:-]+$/.test(payload.run_id)) {
|
|
237
|
+
errors.push(`run_id contains invalid characters: "${payload.run_id}"`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// test_case_uuid — reuse UuidValidator for the canonical check
|
|
241
|
+
if (!payload.test_case_uuid) {
|
|
242
|
+
errors.push('test_case_uuid is required');
|
|
243
|
+
} else {
|
|
244
|
+
// Require nid-prefixed uuid-v4 shape. Duplicated here to avoid a
|
|
245
|
+
// circular require between PayloadBuilder and UuidValidator.
|
|
246
|
+
const uuidPattern = /^\d+-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
247
|
+
if (!uuidPattern.test(payload.test_case_uuid)) {
|
|
248
|
+
errors.push(`test_case_uuid does not match {nid}-{uuid-v4} shape: "${payload.test_case_uuid}"`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// status
|
|
253
|
+
const ALLOWED_STATUSES = new Set(['passed', 'failed', 'skipped']);
|
|
254
|
+
if (!payload.status) {
|
|
255
|
+
errors.push('status is required');
|
|
256
|
+
} else if (!ALLOWED_STATUSES.has(String(payload.status).toLowerCase())) {
|
|
257
|
+
errors.push(`status must be one of {passed, failed, skipped} (got "${payload.status}")`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// source — must be 'automation' when set (C1 contract)
|
|
261
|
+
if (payload.source !== undefined && payload.source !== 'automation') {
|
|
262
|
+
errors.push(`source must be "automation" if provided (got "${payload.source}")`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// browser
|
|
266
|
+
if (payload.browser !== undefined) {
|
|
267
|
+
if (typeof payload.browser !== 'string' || payload.browser.length === 0) {
|
|
268
|
+
errors.push('browser must be a non-empty string when provided');
|
|
269
|
+
} else if (payload.browser.length > 255) {
|
|
270
|
+
errors.push(`browser exceeds 255 chars (got ${payload.browser.length})`);
|
|
271
|
+
} else if (!/^[a-zA-Z0-9 .\-_(),\/]+$/.test(payload.browser)) {
|
|
272
|
+
errors.push(`browser contains invalid characters: "${payload.browser}"`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// environment
|
|
277
|
+
if (payload.environment !== undefined && payload.environment !== '') {
|
|
278
|
+
if (typeof payload.environment !== 'string') {
|
|
279
|
+
errors.push(`environment must be a string (got ${typeof payload.environment})`);
|
|
280
|
+
} else if (payload.environment.length > 255) {
|
|
281
|
+
errors.push(`environment exceeds 255 chars (got ${payload.environment.length})`);
|
|
282
|
+
} else if (!/^[a-zA-Z0-9._-]+$/.test(payload.environment)) {
|
|
283
|
+
errors.push(`environment contains invalid characters: "${payload.environment}"`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// duration
|
|
288
|
+
if (payload.duration !== undefined && payload.duration !== null) {
|
|
289
|
+
const d = payload.duration;
|
|
290
|
+
if (typeof d !== 'number' || !Number.isFinite(d) || d < 0 || !Number.isInteger(d)) {
|
|
291
|
+
errors.push(`duration must be a non-negative integer (got ${d})`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// timestamp
|
|
296
|
+
if (payload.timestamp !== undefined && payload.timestamp !== null) {
|
|
297
|
+
const t = payload.timestamp;
|
|
298
|
+
if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0 || !Number.isInteger(t)) {
|
|
299
|
+
errors.push(`timestamp must be a positive integer (got ${t})`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { valid: errors.length === 0, errors };
|
|
304
|
+
}
|
|
305
|
+
|
|
215
306
|
/**
|
|
216
307
|
* Validate run creation options
|
|
217
308
|
* @param {Object} options - Run options
|
package/src/utils/index.js
CHANGED
|
@@ -6,6 +6,13 @@ const UuidValidator = require('./UuidValidator');
|
|
|
6
6
|
const PayloadBuilder = require('./PayloadBuilder');
|
|
7
7
|
const RunDataNormalizer = require('./RunDataNormalizer');
|
|
8
8
|
const { mapAppqUuid } = require('./mapAppqUuid');
|
|
9
|
+
const {
|
|
10
|
+
setupAuth,
|
|
11
|
+
authStatePath,
|
|
12
|
+
authStateDir,
|
|
13
|
+
envVarNames,
|
|
14
|
+
sanitizeRole,
|
|
15
|
+
} = require('./setupAuth');
|
|
9
16
|
const logger = require('./logger');
|
|
10
17
|
|
|
11
18
|
module.exports = {
|
|
@@ -13,5 +20,10 @@ module.exports = {
|
|
|
13
20
|
PayloadBuilder,
|
|
14
21
|
RunDataNormalizer,
|
|
15
22
|
mapAppqUuid,
|
|
23
|
+
setupAuth,
|
|
24
|
+
authStatePath,
|
|
25
|
+
authStateDir,
|
|
26
|
+
envVarNames,
|
|
27
|
+
sanitizeRole,
|
|
16
28
|
logger
|
|
17
29
|
};
|
package/src/utils/logger.js
CHANGED
|
@@ -5,7 +5,8 @@ const LOG_LEVELS = {
|
|
|
5
5
|
DEBUG: 3
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
//
|
|
8
|
+
// Regex-based sanitization catches value-shaped secrets inside string
|
|
9
|
+
// payloads (raw JWTs in error messages, axios request dumps, etc).
|
|
9
10
|
const SENSITIVE_PATTERNS = [
|
|
10
11
|
// API Keys
|
|
11
12
|
{ pattern: /(appq_\w+_[a-f0-9]{32})/gi, replacement: 'appq_***_REDACTED' },
|
|
@@ -30,6 +31,51 @@ const SENSITIVE_PATTERNS = [
|
|
|
30
31
|
{ pattern: /("session"\s*:\s*")([^"]+)(")/gi, replacement: '$1***REDACTED***$3' }
|
|
31
32
|
];
|
|
32
33
|
|
|
34
|
+
// H5 — field-name-based sanitization catches custom-named fields the regex
|
|
35
|
+
// list will miss: `x-secret`, `myAuthToken`, `dbPassword`, `serviceKey`, etc.
|
|
36
|
+
// Applied recursively before the regex pass. Errs on the side of over-
|
|
37
|
+
// redaction: any object key matching this pattern has its value replaced
|
|
38
|
+
// regardless of nesting depth.
|
|
39
|
+
const SENSITIVE_KEY_PATTERN = /token|secret|key|password|credential|auth/i;
|
|
40
|
+
|
|
41
|
+
function redactByFieldName(value, seen) {
|
|
42
|
+
if (value === null || typeof value !== 'object') return value;
|
|
43
|
+
if (!seen) seen = new WeakSet();
|
|
44
|
+
if (seen.has(value)) return '[Circular]';
|
|
45
|
+
seen.add(value);
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.map((item) => redactByFieldName(item, seen));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result = {};
|
|
52
|
+
for (const k of Object.keys(value)) {
|
|
53
|
+
const v = value[k];
|
|
54
|
+
if (SENSITIVE_KEY_PATTERN.test(k)) {
|
|
55
|
+
if (v === null || v === undefined) {
|
|
56
|
+
// Preserve null/undefined so consumers can detect "field present
|
|
57
|
+
// but empty" without leaking real values.
|
|
58
|
+
result[k] = v;
|
|
59
|
+
} else if (typeof v === 'object') {
|
|
60
|
+
// Recurse into nested objects/arrays. A sensitive container like
|
|
61
|
+
// `credentials: { user, password }` keeps its structure but its
|
|
62
|
+
// leaf values get redacted by the inner pass, so debug output
|
|
63
|
+
// still tells you which fields existed.
|
|
64
|
+
result[k] = redactByFieldName(v, seen);
|
|
65
|
+
} else if (typeof v === 'string' && /^Bearer\s+/i.test(v)) {
|
|
66
|
+
// Preserve scheme prefix for Authorization-style headers so the
|
|
67
|
+
// log reader can tell what kind of token was redacted.
|
|
68
|
+
result[k] = v.replace(/^(Bearer\s+).*$/i, '$1***REDACTED***');
|
|
69
|
+
} else {
|
|
70
|
+
result[k] = '***REDACTED***';
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
result[k] = redactByFieldName(v, seen);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
class Logger {
|
|
34
80
|
constructor(level = 'INFO') {
|
|
35
81
|
this.level = LOG_LEVELS[level.toUpperCase()] || LOG_LEVELS.INFO;
|
|
@@ -55,17 +101,25 @@ class Logger {
|
|
|
55
101
|
return sanitized;
|
|
56
102
|
}
|
|
57
103
|
|
|
58
|
-
// Handle objects and arrays by
|
|
104
|
+
// Handle objects and arrays: redact by field name first (catches
|
|
105
|
+
// custom-named credential fields), then apply the regex pass to any
|
|
106
|
+
// remaining string values for value-shaped secrets.
|
|
59
107
|
if (typeof data === 'object') {
|
|
60
108
|
try {
|
|
61
|
-
|
|
109
|
+
const redacted = redactByFieldName(data);
|
|
110
|
+
let jsonString = JSON.stringify(redacted);
|
|
62
111
|
SENSITIVE_PATTERNS.forEach(({ pattern, replacement }) => {
|
|
63
112
|
jsonString = jsonString.replace(pattern, replacement);
|
|
64
113
|
});
|
|
65
114
|
return JSON.parse(jsonString);
|
|
66
115
|
} catch (error) {
|
|
67
|
-
// If JSON
|
|
68
|
-
|
|
116
|
+
// If JSON serialization fails, return the field-redacted form
|
|
117
|
+
// rather than the raw data — never leak unfiltered objects.
|
|
118
|
+
try {
|
|
119
|
+
return redactByFieldName(data);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return data;
|
|
122
|
+
}
|
|
69
123
|
}
|
|
70
124
|
}
|
|
71
125
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* setupAuth — portable Playwright storageState resolution.
|
|
3
|
+
*
|
|
4
|
+
* Returns a deterministic file path. The same path resolves to the same
|
|
5
|
+
* file regardless of execution context, so the same generated/authored
|
|
6
|
+
* test script runs unchanged in:
|
|
7
|
+
*
|
|
8
|
+
* - Appliqation's executor (the LoginHelper writes the file at this
|
|
9
|
+
* path during validation prep, using AWS Secrets Manager-backed
|
|
10
|
+
* credentials)
|
|
11
|
+
*
|
|
12
|
+
* - Customer CI / local (the `appq-auth-setup` CLI writes the file at
|
|
13
|
+
* this path, using credentials from env vars per the convention
|
|
14
|
+
* downloaded from Project Settings → "download .env for CI")
|
|
15
|
+
*
|
|
16
|
+
* setupAuth() does NOT perform login itself — that's the CLI's job (CI)
|
|
17
|
+
* or the executor's job (Appliqation cloud). It only computes the path.
|
|
18
|
+
*
|
|
19
|
+
* Usage in a Playwright test file:
|
|
20
|
+
*
|
|
21
|
+
* const { mapAppqUuid, setupAuth } = require('@appliqation/automation-sdk/utils');
|
|
22
|
+
*
|
|
23
|
+
* test.use({ storageState: setupAuth({ project_id: 126, role: 'default' }) });
|
|
24
|
+
*
|
|
25
|
+
* test('...', async ({ page }, testInfo) => {
|
|
26
|
+
* mapAppqUuid(testInfo, '<uuid>');
|
|
27
|
+
* // body — already authenticated
|
|
28
|
+
* });
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Default base directory for storageState files. Overridable via
|
|
36
|
+
* APPQ_AUTH_STATE_DIR env var (Appliqation executor sets this per-run
|
|
37
|
+
* for tenant isolation; customers normally don't need to).
|
|
38
|
+
*/
|
|
39
|
+
function authStateDir() {
|
|
40
|
+
return process.env.APPQ_AUTH_STATE_DIR
|
|
41
|
+
|| path.join(os.homedir(), '.appq-auth');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compute the canonical storageState path for a given project + role.
|
|
46
|
+
* Pure function — no I/O. Both the SDK CLI and the Appliqation executor
|
|
47
|
+
* call this same function so they always agree on the location.
|
|
48
|
+
*/
|
|
49
|
+
function authStatePath({ project_id, role = 'default' }) {
|
|
50
|
+
if (project_id === undefined || project_id === null || project_id === '') {
|
|
51
|
+
throw new Error('authStatePath requires project_id');
|
|
52
|
+
}
|
|
53
|
+
const safeRole = sanitizeRole(role);
|
|
54
|
+
return path.join(authStateDir(), `project-${project_id}-${safeRole}.json`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The public entry point. Returns a string suitable for Playwright's
|
|
59
|
+
* `storageState` option (a file path).
|
|
60
|
+
*/
|
|
61
|
+
function setupAuth({ project_id, role = 'default' } = {}) {
|
|
62
|
+
if (project_id === undefined || project_id === null || project_id === '') {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'setupAuth requires project_id. Generated test files include this '
|
|
65
|
+
+ 'as a literal; for hand-authored tests, copy from your project URL '
|
|
66
|
+
+ 'in Appliqation.'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return authStatePath({ project_id, role });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lowercase + alphanumeric/underscore/hyphen only. Mirrors the role-name
|
|
74
|
+
* validation in the Appliqation project settings UI so paths line up.
|
|
75
|
+
*/
|
|
76
|
+
function sanitizeRole(role) {
|
|
77
|
+
return String(role).toLowerCase().replace(/[^a-z0-9_-]/g, '_');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Env-var name convention for the CLI (and any other consumer) to look
|
|
82
|
+
* up credentials. Matches what the "download .env for CI" feature in
|
|
83
|
+
* Appliqation generates. Exported so the CLI and the SDK never drift.
|
|
84
|
+
*/
|
|
85
|
+
function envVarNames({ project_id, role }) {
|
|
86
|
+
const safeRole = sanitizeRole(role).toUpperCase();
|
|
87
|
+
return {
|
|
88
|
+
username: `APPQ_PROJECT_${project_id}_${safeRole}_USERNAME`,
|
|
89
|
+
password: `APPQ_PROJECT_${project_id}_${safeRole}_PASSWORD`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
setupAuth,
|
|
95
|
+
authStatePath,
|
|
96
|
+
authStateDir,
|
|
97
|
+
envVarNames,
|
|
98
|
+
sanitizeRole,
|
|
99
|
+
};
|