@skyramp/skyramp 1.3.13 → 1.3.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/skyramp",
3
- "version": "1.3.13",
3
+ "version": "1.3.15",
4
4
  "description": "module for leveraging skyramp cli functionality",
5
5
  "scripts": {
6
6
  "lint": "eslint 'src/**/*.js' 'src/**/*.ts' --fix",
@@ -4,6 +4,18 @@ const path = require('path');
4
4
  const crypto = require('crypto');
5
5
  const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");
6
6
 
7
+ const HEAD_TIMEOUT_MS = 30_000; // 30s for HEAD/ETag check
8
+ const DOWNLOAD_TIMEOUT_MS = 300_000; // 5min for binary download
9
+ const SCRIPT_TIMEOUT_MS = 600_000; // 10min global script timeout
10
+
11
+ // Global script timeout to prevent hanging indefinitely
12
+ const scriptTimer = setTimeout(() => {
13
+ log('error', `Script timed out after ${SCRIPT_TIMEOUT_MS / 1000}s. This is likely a network connectivity issue.`);
14
+ log('error', `Please check your network connection and retry by running: npm install @skyramp/skyramp`);
15
+ process.exit(1);
16
+ }, SCRIPT_TIMEOUT_MS);
17
+ scriptTimer.unref();
18
+
7
19
  function log(level, message) {
8
20
  const timestamp = new Date().toISOString();
9
21
  console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`);
@@ -79,13 +91,22 @@ async function calculateMD5(filePath) {
79
91
 
80
92
  async function getETag(url) {
81
93
  return new Promise((resolve, reject) => {
82
- https.get(url, { method: 'HEAD' }, res => {
94
+ const req = https.get(url, { method: 'HEAD', timeout: HEAD_TIMEOUT_MS }, res => {
83
95
  if (res.statusCode !== 200) {
96
+ res.resume(); // Drain response to free the socket
84
97
  reject(new Error(`HTTP ${res.statusCode}: ${url}`));
85
98
  return;
86
99
  }
100
+ res.on('error', (err) => {
101
+ reject(new Error(`Response stream error during HEAD request: ${err.message}`));
102
+ });
87
103
  resolve(res.headers['etag']?.replace(/"/g, '')); // Remove quotes from ETag
88
- }).on('error', (err) => {
104
+ });
105
+ req.on('timeout', () => {
106
+ req.destroy();
107
+ reject(new Error(`HEAD request timed out after ${HEAD_TIMEOUT_MS / 1000}s: ${url}`));
108
+ });
109
+ req.on('error', (err) => {
89
110
  log('error', `Error during HEAD request to ${url}: ${err.message}`);
90
111
  reject(err);
91
112
  });
@@ -119,22 +140,61 @@ async function download(url, dest, options = {}) {
119
140
  });
120
141
  } else {
121
142
  // Public URL download using https
122
- https.get(url, res => {
143
+ let rejected = false;
144
+ const fail = (err) => {
145
+ if (rejected) return;
146
+ rejected = true;
147
+ // Clean up partial file
148
+ try { fs.unlinkSync(dest); } catch (_) {}
149
+ reject(err);
150
+ };
151
+
152
+ const req = https.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, res => {
123
153
  if (res.statusCode !== 200) {
154
+ res.resume(); // Drain response to free the socket
124
155
  const error = new Error(`HTTP ${res.statusCode}: ${url}`);
125
156
  log('error', `Failed to download ${url}: ${error.message}`);
126
- reject(error);
157
+ fail(error);
127
158
  return;
128
159
  }
129
160
  const file = fs.createWriteStream(dest);
161
+ let downloaded = 0;
162
+ const contentLength = parseInt(res.headers['content-length'], 10);
163
+ let lastLogTime = Date.now();
164
+
165
+ res.on('data', (chunk) => {
166
+ downloaded += chunk.length;
167
+ const now = Date.now();
168
+ if (now - lastLogTime > 10_000) { // Log progress every 10s
169
+ const pct = contentLength ? ` (${Math.round(downloaded / contentLength * 100)}%)` : '';
170
+ log('info', `Downloading ${path.basename(dest)}: ${(downloaded / 1024 / 1024).toFixed(1)} MB${pct}`);
171
+ lastLogTime = now;
172
+ }
173
+ });
174
+
175
+ res.on('error', (err) => {
176
+ file.destroy();
177
+ fail(new Error(`Response stream error: ${err.message}`));
178
+ });
179
+
180
+ file.on('error', (err) => {
181
+ res.destroy();
182
+ fail(new Error(`File write error: ${err.message}`));
183
+ });
184
+
130
185
  res.pipe(file);
131
186
  file.on('finish', () => {
132
187
  log('info', `Successfully downloaded ${url} to ${dest}`);
133
188
  file.close(resolve);
134
189
  });
135
- }).on('error', (err) => {
190
+ });
191
+ req.on('timeout', () => {
192
+ req.destroy();
193
+ fail(new Error(`Download timed out after ${DOWNLOAD_TIMEOUT_MS / 1000}s: ${url}`));
194
+ });
195
+ req.on('error', (err) => {
136
196
  log('error', `Error during download from ${url}: ${err.message}`);
137
- reject(err);
197
+ fail(err);
138
198
  });
139
199
  }
140
200
  });
@@ -175,6 +235,23 @@ async function download(url, dest, options = {}) {
175
235
  }
176
236
 
177
237
  await download(url, file.dest, options);
238
+
239
+ // Verify downloaded file integrity via ETag/MD5
240
+ if (!S3_PRIVATE) {
241
+ try {
242
+ const remoteETag = await getETag(url);
243
+ const localHash = await calculateMD5(file.dest);
244
+ if (remoteETag && remoteETag !== localHash) {
245
+ log('error', `Hash mismatch for ${file.name}: expected ${remoteETag}, got ${localHash}`);
246
+ try { fs.unlinkSync(file.dest); } catch (_) {}
247
+ throw new Error(`Integrity check failed for ${file.name}. Please check your network connection and retry.`);
248
+ }
249
+ } catch (hashErr) {
250
+ if (hashErr.message.startsWith('Integrity check failed')) throw hashErr;
251
+ log('warn', `Could not verify hash for ${file.name}: ${hashErr.message}`);
252
+ }
253
+ }
254
+
178
255
  log('info', `✅ Saved ${file.name} to ${file.dest}`);
179
256
  } catch (e) {
180
257
  log('error', `❌ Failed to process ${file.name}: ${e.message}`);
@@ -36,6 +36,11 @@ export class MockV2 {
36
36
  */
37
37
  requestBody?: string | null;
38
38
 
39
+ /**
40
+ * Optional request form params for request-aware matching
41
+ */
42
+ requestFormParams?: Record<string, unknown> | null;
43
+
39
44
  /**
40
45
  * Optional data override object
41
46
  */
@@ -83,6 +88,7 @@ export class MockV2 {
83
88
  status_code: number;
84
89
  response_body: string;
85
90
  request_body?: string;
91
+ request_form_params?: Record<string, unknown>;
86
92
  data_override?: Record<string, unknown>;
87
93
  client_id?: string;
88
94
  };
@@ -38,6 +38,7 @@ class MockV2 {
38
38
  this.statusCode = options.statusCode || 201;
39
39
  this.responseBody = (options.body !== undefined ? options.body : options.responseBody) || '';
40
40
  this.requestBody = options.requestBody || null;
41
+ this.requestFormParams = options.requestFormParams || null;
41
42
  this.dataOverride = options.dataOverride || null;
42
43
  this.clientID = options.clientID || null;
43
44
  } else {
@@ -48,6 +49,7 @@ class MockV2 {
48
49
  this.statusCode = statusCode;
49
50
  this.responseBody = responseBody;
50
51
  this.requestBody = requestBody;
52
+ this.requestFormParams = null;
51
53
  this.dataOverride = dataOverride;
52
54
  this.clientID = null;
53
55
  }
@@ -70,6 +72,10 @@ class MockV2 {
70
72
  result.request_body = this.requestBody;
71
73
  }
72
74
 
75
+ if (this.requestFormParams !== null) {
76
+ result.request_form_params = this.requestFormParams;
77
+ }
78
+
73
79
  if (this.dataOverride !== null) {
74
80
  result.data_override = this.dataOverride;
75
81
  }
@@ -1,3 +1,6 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
1
4
  /**
2
5
  * The `MultipartParam` class represents a multipart/form-data parameter for file uploads or value-based content.
3
6
  * @class
@@ -8,21 +11,22 @@ class MultipartParam {
8
11
  * @constructor
9
12
  * @param {Object} options - An options object containing all parameters.
10
13
  * @param {string} options.name - The name of the multipart parameter (required).
11
- * @param {string} [options.filename=""] - The filename for file uploads.
14
+ * @param {string} [options.filename=""] - Filename for file uploads. When this is a path to an
15
+ * existing file on disk, the file is read and base64-encoded automatically and the basename
16
+ * is used as the Content-Disposition filename sent to the server.
12
17
  * @param {boolean} [options.decoded=false] - Whether the content should be decoded.
13
18
  * @param {string} [options.value=null] - The value for value-based parameters.
14
- *
19
+ *
15
20
  * @example
16
- * // File upload parameter
17
- * new MultipartParam({
21
+ * // File upload — filename is a local path, content is read automatically
22
+ * new MultipartParam({
18
23
  * name: 'file',
19
- * filename: 'document.pdf',
20
- * decoded: true
24
+ * filename: '/path/to/document.pdf'
21
25
  * })
22
- *
26
+ *
23
27
  * @example
24
28
  * // Value-based parameter
25
- * new MultipartParam({
29
+ * new MultipartParam({
26
30
  * name: 'attributes',
27
31
  * value: '{"product_id": 3}',
28
32
  * filename: '',
@@ -33,11 +37,21 @@ class MultipartParam {
33
37
  if (typeof options !== 'object' || options === null || !('name' in options)) {
34
38
  throw new Error('MultipartParam requires an options object with a name property')
35
39
  }
36
-
37
- this.name = options.name
38
- this.value = options.value || null
39
- this.filename = options.filename || ""
40
+
41
+ this.name = options.name
40
42
  this.decoded = options.decoded || false
43
+
44
+ const filenameOpt = options.filename || ""
45
+
46
+ if (filenameOpt && fs.existsSync(filenameOpt)) {
47
+ // filename is a local filesystem path — read content and use basename as display name
48
+ this.value = fs.readFileSync(filenameOpt).toString('base64')
49
+ this.decoded = false
50
+ this.filename = path.basename(filenameOpt)
51
+ } else {
52
+ this.filename = filenameOpt
53
+ this.value = options.value || null
54
+ }
41
55
  }
42
56
 
43
57
  toJson() {
@@ -36,7 +36,7 @@ class RequestV2 {
36
36
  this.pathParams = options.pathParams || {};
37
37
  this.queryParams = options.queryParams || {};
38
38
  this.formParams = options.formParams || {};
39
- this.multipartParams = options.multipartParams || [];
39
+ this.multipartParams = options.multipartParams || options.multipart_params || [];
40
40
  this.expectedCode = options.expectedCode || '';
41
41
  this.description = options.description || '';
42
42
  this.insecure = options.insecure || false;
@@ -140,6 +140,7 @@ interface GenerateRestTestOptions {
140
140
  chainingKey?: string;
141
141
  parentRequestData?: Record<string, string> | string;
142
142
  parentStatusCode?: Record<string, string> | string;
143
+ parentFormParams?: Record<string, string> | string;
143
144
  requestAware?: boolean;
144
145
  providerMode?: boolean;
145
146
  consumerMode?: boolean;
@@ -104,12 +104,14 @@ const generateRestTestWrapper = lib.func('generateRestTestWrapper', 'string', [
104
104
  'string', // chainingKey
105
105
  'string', // parentRequestData
106
106
  'string', // parentStatusCode
107
+ 'string', // parentFormParams
107
108
  'bool', // requestAware
108
109
  'bool', // providerMode
109
110
  'bool', // consumerMode
110
111
  'string', // providerOutput (contract test)
111
- 'string', // consumerOutput (contract test)
112
- 'bool' // skipProvisionParents
112
+ 'string', // consumerOutput (contract test)
113
+ 'bool', // skipProvisionParents
114
+ 'int' // mockPort
113
115
  ]);
114
116
  const generateRestMockWrapper = lib.func('generateRestMockWrapper', 'string', [
115
117
  'string', // uri
@@ -124,17 +126,17 @@ const generateRestMockWrapper = lib.func('generateRestMockWrapper', 'string', [
124
126
  'string', // k8sNamespace
125
127
  'string', // k8sConfig
126
128
  'string', // k8sContext
129
+ 'string', // requestData
127
130
  'string', // responseData
128
131
  'string', // responseStatusCode
129
132
  'bool', // force
130
133
  'bool', // deployDashboard
131
134
  'bool', // requestAware
132
135
  'string', // formParams
133
- 'string', // pathParams
134
- 'string', // queryParams
135
136
  'string', // apiSchema
136
137
  'string', // traceFilePath
137
- 'string' // entryPoint
138
+ 'string', // entryPoint
139
+ 'int' // mockPort
138
140
  ]);
139
141
  const traceCollectWrapper = lib.func('traceCollectWrapper', 'string', ['string', 'string', 'bool', 'string', 'string']);
140
142
  const analyzeOpenapiWrapper = lib.func('analyzeOpenapiWrapper', 'string', ['string', 'string']);
@@ -895,6 +897,9 @@ class SkyrampClient {
895
897
  const parentStatusCode = typeof options.parentStatusCode === 'string'
896
898
  ? options.parentStatusCode
897
899
  : JSON.stringify(options.parentStatusCode || {});
900
+ const parentFormParams = typeof options.parentFormParams === 'string'
901
+ ? options.parentFormParams
902
+ : JSON.stringify(options.parentFormParams || {});
898
903
 
899
904
  generateRestTestWrapper.async(
900
905
  options.testType || "",
@@ -947,12 +952,14 @@ class SkyrampClient {
947
952
  options.chainingKey || "",
948
953
  parentRequestData || "",
949
954
  parentStatusCode || "",
955
+ parentFormParams || "",
950
956
  options.requestAware || false,
951
- options.providerMode || true,
957
+ options.providerMode || false,
952
958
  options.consumerMode || false,
953
959
  options.providerOutput || "",
954
960
  options.consumerOutput || "",
955
- options.skipProvisionParents || true,
961
+ options.skipProvisionParents || false,
962
+ options.mockPort || 0,
956
963
  (err, res) => {
957
964
  if (err) {
958
965
  reject(err);
@@ -979,17 +986,17 @@ class SkyrampClient {
979
986
  options.k8sNamespace || "",
980
987
  options.k8sConfig || "",
981
988
  options.k8sContext || "",
989
+ options.requestData || "",
982
990
  options.responseData || "",
983
991
  options.responseStatusCode || "",
984
992
  options.force || false,
985
993
  options.deployDashboard || false,
986
994
  options.requestAware || false,
987
995
  options.formParams || "",
988
- options.pathParams || "",
989
- options.queryParams || "",
990
996
  JSON.stringify(options.apiSchema || []),
991
997
  options.traceFilePath || "",
992
998
  options.entrypoint || "",
999
+ options.mockPort || 0,
993
1000
  (err, res) => {
994
1001
  if (err) {
995
1002
  reject(err);
@@ -1175,6 +1175,11 @@ class SkyrampPlaywrightPage {
1175
1175
  return newLocator
1176
1176
  }
1177
1177
 
1178
+ frameLocator(selector) {
1179
+ const originalFrameLocator = this._page.frameLocator(selector);
1180
+ return new SkyrampPlaywrightFrameLocator(this, originalFrameLocator);
1181
+ }
1182
+
1178
1183
  locator(selector, options) {
1179
1184
  const originalLocator = this._page.locator(selector, options);
1180
1185
  return this.newSkyrampPlaywrightLocator(originalLocator, selector, options);
@@ -1434,5 +1439,6 @@ function expect(obj, testInfo) {
1434
1439
 
1435
1440
  module.exports = {
1436
1441
  newSkyrampPlaywrightPage,
1442
+ SkyrampPlaywrightPage,
1437
1443
  expect,
1438
1444
  };
@@ -26,7 +26,7 @@ export interface Service {
26
26
  serviceName: string;
27
27
  language?: "python" | "typescript" | "javascript" | "java";
28
28
  framework?: "playwright" | "pytest" | "robot" | "junit";
29
- outputDir: string;
29
+ testDirectory?: string;
30
30
  api?: ServiceApi;
31
31
  runtimeDetails?: ServiceRuntimeDetails;
32
32
  }
package/src/workspace.js CHANGED
@@ -26,7 +26,7 @@ const serviceSchema = z.object({
26
26
  framework: z
27
27
  .enum(['playwright', 'pytest', 'robot', 'junit'])
28
28
  .optional(),
29
- outputDir: z.string().optional(),
29
+ testDirectory: z.string().optional(),
30
30
  api: z
31
31
  .object({
32
32
  schemaPath: z.string().optional(),