@capillarytech/cap-ui-utils 3.0.20 → 3.1.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.
@@ -0,0 +1,174 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import debugMode from './debugModeUtil';
4
+
5
+ /**
6
+ * Captures a full-page screenshot after every click and saves them under
7
+ * reports/debug/screenshots/<module>/<test>/<NNN>_<command>.png.
8
+ *
9
+ * Off by default; enabled only when FULL_DEBUG_MODE=true. When off, every
10
+ * method is a no-op so there is zero overhead.
11
+ *
12
+ * Driven by the WDIO afterCommand hook. Only click commands trigger a capture,
13
+ * so other commands (typing, lookups, navigation) don't spam the report.
14
+ *
15
+ * Also writes a steps.json per test mapping each click (step index) to its
16
+ * screenshot file and the wall-clock time it fired. The compare tool uses the
17
+ * click times to attribute recorded request payloads to the click that
18
+ * triggered them (request.timestamp falls between consecutive click times).
19
+ */
20
+
21
+ interface RecordedStep {
22
+ step: number;
23
+ file: string;
24
+ command: string;
25
+ clickTime: string;
26
+ }
27
+
28
+ // WDIO command names that represent a click worth screenshotting.
29
+ const INTERACTION_COMMANDS = new Set([
30
+ 'click',
31
+ 'doubleClick',
32
+ ]);
33
+
34
+ // afterCommand fires before the post-click content has rendered; wait this long
35
+ // so the screenshot reflects the settled page. Tunable via env.
36
+ const SETTLE_MS = Number(process.env.FULL_DEBUG_SCREENSHOT_DELAY_MS) || 500;
37
+
38
+ // Hard cap on a single CDP screenshot. The capture can hang indefinitely when it
39
+ // fires while the page is mid-navigation (e.g. during login), which would block
40
+ // the awaiting afterCommand hook and stall the whole run until the Mocha hook
41
+ // timeout. Bounding it guarantees screenshots can never block automations.
42
+ const CAPTURE_TIMEOUT_MS = Number(process.env.FULL_DEBUG_SCREENSHOT_TIMEOUT_MS) || 8000;
43
+
44
+ class ScreenshotRecorder {
45
+ private enabled = debugMode.isCaptureEnabled();
46
+ private outputDir = path.join(process.cwd(), 'reports', 'debug', 'screenshots');
47
+ private currentTestTitle = '';
48
+ private counter = 0;
49
+ // Last saved image; used to drop byte-identical duplicate captures.
50
+ private lastImage: Buffer | null = null;
51
+ // Guard against re-entrancy: our own cdp/screenshot calls also fire afterCommand.
52
+ private capturing = false;
53
+ // Per-test record of clicks → screenshot file + time, for request correlation.
54
+ private steps: RecordedStep[] = [];
55
+
56
+ isEnabled(): boolean {
57
+ return this.enabled;
58
+ }
59
+
60
+ /** Reset the per-test counter and remember the current test. */
61
+ startTest(testTitle: string): void {
62
+ if (!this.enabled) return;
63
+ this.currentTestTitle = testTitle || 'unknown_test';
64
+ this.counter = 0;
65
+ this.lastImage = null;
66
+ this.steps = [];
67
+ }
68
+
69
+ /** Capture a full-page screenshot if commandName is a click. */
70
+ async captureAfter(commandName: string): Promise<void> {
71
+ if (!this.enabled || this.capturing) return;
72
+ if (!INTERACTION_COMMANDS.has(commandName)) return;
73
+ // No active test means we're in the login/setup phase (the root before-all
74
+ // hook). Capturing here is pointless and, worse, the CDP screenshot can hang
75
+ // mid-navigation and block that hook until its Mocha timeout. Skip it.
76
+ if (!this.currentTestTitle) return;
77
+
78
+ this.capturing = true;
79
+ // Stamp the click time before the settle pause so it lines up with when
80
+ // the click actually fired (and thus with the requests it triggered).
81
+ const clickTime = new Date().toISOString();
82
+ try {
83
+ // Let the post-click content render before capturing.
84
+ await browser.pause(SETTLE_MS);
85
+
86
+ // captureBeyondViewport => full scrollable page, not just the viewport.
87
+ // Bound the CDP call: it can hang indefinitely if it fires while the page
88
+ // is mid-navigation, which would block the awaiting afterCommand hook.
89
+ const capturePromise: Promise<any> = browser.cdp('Page', 'captureScreenshot', {
90
+ format: 'png',
91
+ captureBeyondViewport: true,
92
+ });
93
+ // If the timeout wins, the CDP promise may still settle (or reject) later;
94
+ // swallow it so a late rejection never surfaces as an unhandled rejection.
95
+ capturePromise.catch(() => {});
96
+
97
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
98
+ const res: any = await Promise.race([
99
+ capturePromise,
100
+ new Promise((resolve) => {
101
+ timeoutHandle = setTimeout(() => resolve(null), CAPTURE_TIMEOUT_MS);
102
+ }),
103
+ ]);
104
+ // Clear the fallback timer once the race settles so a pending timeout
105
+ // can't keep the Node event loop alive (delaying process exit by up to
106
+ // CAPTURE_TIMEOUT_MS) when the CDP call won.
107
+ if (timeoutHandle) clearTimeout(timeoutHandle);
108
+ if (!res?.data) return;
109
+
110
+ const image = Buffer.from(res.data, 'base64');
111
+ // afterCommand fires twice per click; once settled both frames are
112
+ // identical, so skip the byte-identical repeat.
113
+ if (this.lastImage && this.lastImage.equals(image)) return;
114
+ this.lastImage = image;
115
+
116
+ const moduleName = process.env.module || 'unknown_module';
117
+ const safeTitle = (this.currentTestTitle || '_pre-test')
118
+ .replace(/[^a-zA-Z0-9_-]+/g, '_')
119
+ .slice(0, 120);
120
+ const dir = path.join(this.outputDir, moduleName, safeTitle);
121
+ fs.mkdirSync(dir, { recursive: true });
122
+
123
+ this.counter += 1;
124
+ const seq = String(this.counter).padStart(3, '0');
125
+ const fileName = `${seq}_${commandName}.png`;
126
+ fs.writeFileSync(path.join(dir, fileName), image);
127
+
128
+ this.steps.push({
129
+ step: this.counter,
130
+ file: fileName,
131
+ command: commandName,
132
+ clickTime,
133
+ });
134
+ } catch (err) {
135
+ console.log(`[screenshotRecorder] failed to capture after ${commandName}: ${err}`);
136
+ } finally {
137
+ this.capturing = false;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Write the per-test steps.json alongside this test's screenshots, mapping
143
+ * each click step to its screenshot file and the time it fired. Used by the
144
+ * compare tool to attribute request payloads to the click that triggered
145
+ * them. No-op when nothing was captured.
146
+ */
147
+ flushTest(): void {
148
+ if (!this.enabled) return;
149
+ try {
150
+ if (this.steps.length === 0) return;
151
+
152
+ const moduleName = process.env.module || 'unknown_module';
153
+ const safeTitle = (this.currentTestTitle || '_pre-test')
154
+ .replace(/[^a-zA-Z0-9_-]+/g, '_')
155
+ .slice(0, 120);
156
+ const dir = path.join(this.outputDir, moduleName, safeTitle);
157
+ fs.mkdirSync(dir, { recursive: true });
158
+
159
+ const payload = {
160
+ test: this.currentTestTitle,
161
+ module: moduleName,
162
+ stepCount: this.steps.length,
163
+ steps: this.steps,
164
+ };
165
+ fs.writeFileSync(path.join(dir, 'steps.json'), JSON.stringify(payload, null, 2));
166
+ } catch (err) {
167
+ console.log(`[screenshotRecorder] failed to write steps.json: ${err}`);
168
+ } finally {
169
+ this.steps = [];
170
+ }
171
+ }
172
+ }
173
+
174
+ export default new ScreenshotRecorder();
@@ -0,0 +1,29 @@
1
+ import { beamerApiResp } from "./mockResponse";
2
+
3
+ class setupMock {
4
+ async mockBeamerAPIForPopup() {
5
+ const beamerReq1 = await browser.mock(
6
+ "https://backend.getbeamer.com/initialize" + "**",
7
+ {
8
+ method: "get",
9
+ }
10
+ );
11
+
12
+ beamerReq1.respond(beamerApiResp);
13
+
14
+ const beamerReq2 = await browser.mock(
15
+ "https://backend.getbeamer.com/numberFeatures" + "**",
16
+ {
17
+ method: "get",
18
+ }
19
+ );
20
+
21
+ beamerReq2.respond(
22
+ [{}],
23
+ {
24
+ statusCode: 404,
25
+ }
26
+ );
27
+ }
28
+ }
29
+ export default new setupMock();
@@ -0,0 +1,101 @@
1
+ import elementUtil from "./elementUtil";
2
+
3
+ /** Invalid snippet used to trigger unmatched-braces validation across Engage and Adiona flows. */
4
+ export const UNMATCHED_BRACES_SNIPPET = "{{ my_standard_tag";
5
+
6
+ /** Unsupported/unrecognised tag used to trigger the send-for-approval server-side validation error. */
7
+ export const UNSUPPORTED_TAG = "{{my_invalid_standard_tag}}";
8
+
9
+ /** Full footer / inline error copy for standard-tag validation (SMS, MPush, Web Push, etc.). */
10
+ export const INVALID_LABEL_ERROR_TEXT = "Invalid label, please close all curly braces";
11
+
12
+ /**
13
+ * Footer error container for "Invalid label…" type errors (SMS, Journey SMS).
14
+ * XPath: //div[contains(@class,'error-container')]//div[text()='<message>']
15
+ */
16
+ export function invalidLabelErrorFooterContainer(errorMessage: string) {
17
+ return $(
18
+ `//div[contains(@class,'error-container')]//div[text()='${errorMessage}']`
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Inline error span for "Invalid label…" (Viber, Zalo, Journey Zalo).
24
+ * XPath: //span[contains(@class,'error-message') and contains(.,'Invalid label')]
25
+ */
26
+ export function invalidLabelErrorSpan() {
27
+ return $("//span[contains(@class,'error-message') and contains(.,'Invalid label')]");
28
+ }
29
+
30
+ /**
31
+ * Validation error span below a component-with-label input (MPush Title / Message).
32
+ * XPath: //div[contains(@class,'component-with-label-label') and normalize-space()='<label>']
33
+ * /ancestor::div[contains(@class,'component-with-label')]//span[contains(@class,'error-message')]
34
+ * Use for channels where the error appears inline under the input rather than in the footer.
35
+ */
36
+ export function componentWithLabelErrorSpan(label: string) {
37
+ return $(
38
+ `//div[contains(@class,'component-with-label-label') and normalize-space()='${label}']/ancestor::div[contains(@class,'component-with-label')]//span[contains(@class,'error-message')]`
39
+ );
40
+ }
41
+
42
+ /**
43
+ * CodeMirror email editor: click the last cm-line at a stable left-edge offset to acquire focus,
44
+ * wait for the editor to be focused, then press End + Enter to position the cursor on a new last line.
45
+ * Call this before typing a snippet into the editor.
46
+ *
47
+ * @param codeMirrorEditor The CodeMirror content-editable div (aria-placeholder contains "Write your HTML email code here").
48
+ * @param codeMirrorLastLine The last `.cm-line` div inside that editor.
49
+ */
50
+ export async function focusEmailEditorAddNewLineAtEnd(
51
+ codeMirrorEditor: WebdriverIO.Element,
52
+ codeMirrorLastLine: WebdriverIO.Element
53
+ ): Promise<void> {
54
+ await codeMirrorEditor.waitForDisplayed({ timeout: 90000 });
55
+ await codeMirrorLastLine.waitForDisplayed({ timeout: 10000 });
56
+ const size = await codeMirrorLastLine.getSize();
57
+ const xOffset = Math.round(2 - size.width / 2);
58
+ await codeMirrorLastLine.click({ x: xOffset, y: 0 });
59
+ await browser.waitUntil(
60
+ async () => {
61
+ const focused = await browser.execute(() => {
62
+ const codeMirrorEditorEl = document.querySelector(
63
+ 'div[aria-placeholder*="Write your HTML email code here"]'
64
+ );
65
+ if (!codeMirrorEditorEl) return false;
66
+ const active = document.activeElement;
67
+ const editorFocused = active === codeMirrorEditorEl || (active && codeMirrorEditorEl.contains(active));
68
+ const cmEditor = codeMirrorEditorEl.closest(".cm-editor");
69
+ const hasCmFocused =
70
+ (cmEditor && cmEditor.classList.contains("cm-focused")) ||
71
+ (active && active.classList.contains("cm-focused"));
72
+ return !!(editorFocused || hasCmFocused);
73
+ });
74
+ return focused === true;
75
+ },
76
+ {
77
+ timeout: 10000,
78
+ timeoutMsg: "CodeMirror editor did not receive focus after clicking last line",
79
+ interval: 100,
80
+ }
81
+ );
82
+ await browser.keys(["End"]);
83
+ await browser.keys(["Enter"]);
84
+ }
85
+
86
+ /**
87
+ * Inject unmatched-braces snippet, try submit (if clickable), assert via callback, then restore field.
88
+ * Not used for Email (CodeMirror debounce / waitUntil sequencing is bespoke).
89
+ */
90
+ export async function injectAndRestore(
91
+ targetInput: WebdriverIO.Element,
92
+ submitButton: WebdriverIO.Element,
93
+ originalValue: string,
94
+ assertFn: () => Promise<void>
95
+ ): Promise<void> {
96
+ await elementUtil.enterText(targetInput, UNMATCHED_BRACES_SNIPPET);
97
+ await elementUtil.clickIfClickable(submitButton);
98
+ await assertFn();
99
+ await elementUtil.clearByBackspace(UNMATCHED_BRACES_SNIPPET, targetInput);
100
+ await elementUtil.typeText(targetInput, originalValue);
101
+ }
@@ -0,0 +1,84 @@
1
+ import * as fs from 'fs';
2
+ import axios from 'axios';
3
+ import { Uploader } from './uploader';
4
+
5
+ /**
6
+ * Uploads a file to Capillary's internal File Service, mirroring the
7
+ * `uploadData` flow in arya's arya-fileservice-sdk: a raw-binary POST to
8
+ * POST {baseUrl}v2/{namespace}/?fileName={name}&fileNameEncoding=false
9
+ * with the file bytes as the body. The service stores the object (in S3 under
10
+ * the hood) and returns JSON containing file_path / secure_file_path.
11
+ *
12
+ * Config via env vars:
13
+ * FILE_SERVICE_URL - base URL. Defaults to the in-cluster DNS
14
+ * http://file-service.default/ which is ONLY
15
+ * reachable from inside the Capillary cluster.
16
+ * Set this to a reachable host when running
17
+ * outside the cluster.
18
+ * FILE_SERVICE_NAMESPACE - target namespace (must be configured on the
19
+ * file-service side to allow the archive's
20
+ * extension/mime-type and size). Defaults to
21
+ * 'import', which is provisioned on the crm
22
+ * clusters and permits .zip (it stores under a
23
+ * generated UUID key, so use the returned path).
24
+ * FILE_SERVICE_ORG_ID - optional org id (X-CAP-API-AUTH-ORG-ID header)
25
+ * FILE_SERVICE_PUBLIC - "false" to upload as PRIVATE (default PUBLIC)
26
+ *
27
+ * Note: the arya SDK sends no auth header — the service relies on in-cluster
28
+ * network isolation. There is no external/authenticated route here.
29
+ */
30
+ export class FileServiceUploader implements Uploader {
31
+ readonly name = 'Capillary File Service';
32
+ private baseUrl = process.env.FILE_SERVICE_URL || 'http://file-service.default/';
33
+ // Defaults to the 'import' namespace: it is provisioned on the crm clusters
34
+ // (unlike 'default', which does NOT exist server-side and 500s with
35
+ // NoSuchNamespaceException) and permits .zip uploads. Note it stores under a
36
+ // server-generated UUID key, so retrieve via the returned file_path, not the
37
+ // original filename. Override per env with FILE_SERVICE_NAMESPACE.
38
+ private namespace = process.env.FILE_SERVICE_NAMESPACE || 'import';
39
+ private orgId = process.env.FILE_SERVICE_ORG_ID || '';
40
+ private isPublic = process.env.FILE_SERVICE_PUBLIC !== 'false';
41
+ // Hard network timeout so an unresponsive file-service can never hang the
42
+ // run's onComplete hook (which would keep the pod alive forever).
43
+ private timeoutMs = Number(process.env.FILE_SERVICE_TIMEOUT_MS) || 60000;
44
+
45
+ isConfigured(): boolean {
46
+ return Boolean(this.baseUrl && this.namespace);
47
+ }
48
+
49
+ missingConfigMessage(): string {
50
+ const missing: string[] = [];
51
+ if (!this.baseUrl) missing.push('FILE_SERVICE_URL');
52
+ if (!this.namespace) missing.push('FILE_SERVICE_NAMESPACE');
53
+ return missing.join(', ');
54
+ }
55
+
56
+ async upload(localFilePath: string, remoteName: string): Promise<string> {
57
+ const base = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
58
+ const url = `${base}v2/${this.namespace}/?fileName=${encodeURI(remoteName)}&fileNameEncoding=false`;
59
+
60
+ const headers: Record<string, string> = { 'Content-Type': 'application/zip' };
61
+ if (this.isPublic) headers['X-CAP-FS-ACL'] = 'PUBLIC';
62
+ if (this.orgId) headers['X-CAP-API-AUTH-ORG-ID'] = String(this.orgId);
63
+
64
+ const body = fs.readFileSync(localFilePath);
65
+ const res = await axios.post(url, body, {
66
+ headers,
67
+ maxBodyLength: Infinity,
68
+ maxContentLength: Infinity,
69
+ timeout: this.timeoutMs,
70
+ });
71
+
72
+ // The file-service returns HTTP 200 even when the upload fails (e.g.
73
+ // NoSuchNamespaceException), carrying the error in the JSON body. So a
74
+ // 200 is NOT proof of success — only a real file path is. Treat a body
75
+ // without one as a failure so the run doesn't silently report success.
76
+ const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
77
+ const location = data?.secure_file_path || data?.file_path || data?.file?.s3_token;
78
+ if (!location) {
79
+ const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
80
+ throw new Error(`file-service returned no file path (namespace='${this.namespace}'): ${body}`);
81
+ }
82
+ return location;
83
+ }
84
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Destination-agnostic uploader contract. Implement this for each backend
3
+ * (Google Drive now, S3 later) so the orchestrator never changes.
4
+ */
5
+ export interface Uploader {
6
+ /** Human-readable backend name, used in logs. */
7
+ readonly name: string;
8
+
9
+ /** True when all required credentials/config are present. */
10
+ isConfigured(): boolean;
11
+
12
+ /** Describes which config is missing (for a helpful log when not configured). */
13
+ missingConfigMessage(): string;
14
+
15
+ /**
16
+ * Upload a local file and return a human-readable location
17
+ * (URL or id) for logging.
18
+ */
19
+ upload(localFilePath: string, remoteName: string): Promise<string>;
20
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Helpers for antd v6 virtualized lists (`rc-virtual-list`).
3
+ *
4
+ * The list renders only a window of DOM rows at a time. A row that exists in the data
5
+ * model but is outside that window has no DOM node, so `waitForDisplayed` / `click`
6
+ * through WDIO never sees it. These helpers drive the scroll container in the browser
7
+ * until the row is rendered, then click the target directly.
8
+ *
9
+ * Works for both `ant-select-tree` (TreeSelect) and `ant-select-item-option` (Select).
10
+ */
11
+
12
+ interface ScrollToOptions {
13
+ /** CSS selector of a container that scopes the search (popover root). If omitted, document. */
14
+ containerSelector?: string;
15
+ /** Click the checkbox inside the row instead of the row itself. */
16
+ clickCheckbox?: boolean;
17
+ /** Overall timeout in ms. */
18
+ timeout?: number;
19
+ /** Per-iteration pause in ms. */
20
+ interval?: number;
21
+ }
22
+
23
+ /**
24
+ * Scroll a virtualized antd list inside `containerSelector` until a row whose trimmed
25
+ * textContent equals `label` is rendered, then click it (or its checkbox).
26
+ *
27
+ * Returns true on success, throws on timeout.
28
+ */
29
+ export async function scrollAndClickVirtualOption(
30
+ label: string,
31
+ opts: ScrollToOptions = {},
32
+ ): Promise<void> {
33
+ const {
34
+ containerSelector,
35
+ clickCheckbox = true,
36
+ timeout = 15000,
37
+ interval = 300,
38
+ } = opts;
39
+
40
+ await browser.waitUntil(
41
+ async () => {
42
+ return await browser.execute(
43
+ (containerSel: string, targetLabel: string, useCheckbox: boolean) => {
44
+ const root: ParentNode = containerSel
45
+ ? document.querySelector(containerSel) || document
46
+ : document;
47
+
48
+ const ROW_SELECTOR =
49
+ '[role="treeitem"], .ant-select-tree-treenode, .ant-select-item-option';
50
+
51
+ const findRow = (): HTMLElement | null => {
52
+ const rows = Array.from((root as ParentNode).querySelectorAll(ROW_SELECTOR));
53
+ for (const r of rows) {
54
+ if ((r.textContent || '').trim() === targetLabel) return r as HTMLElement;
55
+ }
56
+ return null;
57
+ };
58
+
59
+ // If already rendered, click and return.
60
+ let row = findRow();
61
+ if (row) {
62
+ const checkbox = useCheckbox
63
+ ? (row.querySelector(
64
+ '.ant-select-tree-checkbox, .ant-tree-checkbox, .ant-select-item-option-state',
65
+ ) as HTMLElement | null)
66
+ : null;
67
+ (checkbox || row).click();
68
+ return true;
69
+ }
70
+
71
+ // Find the scroll container.
72
+ const holder = (root as ParentNode).querySelector(
73
+ '.rc-virtual-list-holder, .ant-select-tree-list-holder, .ant-tree-list-holder',
74
+ ) as HTMLElement | null;
75
+ if (!holder) return false;
76
+
77
+ // rc-virtual-list uses the holder's scrollTop to drive the translateY of inner.
78
+ // Advance by one viewport (clientHeight) each tick until we either find the row
79
+ // or hit the end.
80
+ const prev = holder.scrollTop;
81
+ holder.scrollTop = Math.min(
82
+ holder.scrollHeight,
83
+ prev + Math.max(holder.clientHeight, 120),
84
+ );
85
+ // Dispatch scroll so the virtual list re-renders.
86
+ holder.dispatchEvent(new Event('scroll', { bubbles: true }));
87
+
88
+ row = findRow();
89
+ if (row) {
90
+ const checkbox = useCheckbox
91
+ ? (row.querySelector(
92
+ '.ant-select-tree-checkbox, .ant-tree-checkbox, .ant-select-item-option-state',
93
+ ) as HTMLElement | null)
94
+ : null;
95
+ (checkbox || row).click();
96
+ return true;
97
+ }
98
+
99
+ // Reached the bottom without finding it? Caller will timeout if so.
100
+ return holder.scrollTop >= holder.scrollHeight - holder.clientHeight ? false : false;
101
+ },
102
+ containerSelector ?? '',
103
+ label,
104
+ clickCheckbox,
105
+ );
106
+ },
107
+ {
108
+ timeout,
109
+ interval,
110
+ timeoutMsg: `Virtualized option "${label}" not found within ${timeout}ms (container=${containerSelector ?? 'document'})`,
111
+ },
112
+ );
113
+ }
114
+
115
+ export default { scrollAndClickVirtualOption };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capillarytech/cap-ui-utils",
3
- "version": "3.0.20",
3
+ "version": "3.1.0",
4
4
  "description": "Utility functions shared accross all the modules",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -18,5 +18,19 @@
18
18
  "react-ga": "^3.3.1",
19
19
  "react-intl": "6.6.6",
20
20
  "tti-polyfill": "^0.2.2"
21
+ },
22
+ "peerDependencies": {
23
+ "webdriverio": ">=7",
24
+ "@rpii/wdio-commands": ">=7",
25
+ "axios": ">=1",
26
+ "supertest": ">=6",
27
+ "nanoid": ">=3"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "webdriverio": { "optional": true },
31
+ "@rpii/wdio-commands": { "optional": true },
32
+ "axios": { "optional": true },
33
+ "supertest": { "optional": true },
34
+ "nanoid": { "optional": true }
21
35
  }
22
36
  }