@taqwright/taqwright 0.0.24
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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/auto-appium.d.ts +12 -0
- package/dist/auto-appium.js +77 -0
- package/dist/bin/branding.d.ts +6 -0
- package/dist/bin/branding.js +22 -0
- package/dist/bin/index.d.ts +2 -0
- package/dist/bin/index.js +321 -0
- package/dist/bin/init.d.ts +26 -0
- package/dist/bin/init.js +902 -0
- package/dist/bin/inspect.d.ts +9 -0
- package/dist/bin/inspect.js +91 -0
- package/dist/bin/report-branding.d.ts +2 -0
- package/dist/bin/report-branding.js +42 -0
- package/dist/branding-assets.d.ts +1 -0
- package/dist/branding-assets.js +1 -0
- package/dist/capabilities-helpers.d.ts +7 -0
- package/dist/capabilities-helpers.js +14 -0
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.js +86 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +235 -0
- package/dist/discovery-setup.d.ts +1 -0
- package/dist/discovery-setup.js +61 -0
- package/dist/discovery.d.ts +17 -0
- package/dist/discovery.js +55 -0
- package/dist/docs/configuration.html +376 -0
- package/dist/docs/custom-reporters.html +265 -0
- package/dist/docs/docker.html +339 -0
- package/dist/docs/docs.js +173 -0
- package/dist/docs/generating-tests.html +161 -0
- package/dist/docs/images/taqwright-html-report.png +0 -0
- package/dist/docs/index.html +13 -0
- package/dist/docs/installation.html +686 -0
- package/dist/docs/parallel.html +271 -0
- package/dist/docs/running-tests.html +385 -0
- package/dist/docs/styles.css +460 -0
- package/dist/docs/writing-tests.html +565 -0
- package/dist/doctor.d.ts +33 -0
- package/dist/doctor.js +508 -0
- package/dist/expect.d.ts +38 -0
- package/dist/expect.js +96 -0
- package/dist/fixture/artifact-mode.d.ts +2 -0
- package/dist/fixture/artifact-mode.js +7 -0
- package/dist/fixture/index.d.ts +15 -0
- package/dist/fixture/index.js +324 -0
- package/dist/images/taqwright-html-report.png +0 -0
- package/dist/images/taqwright_favicon.png +0 -0
- package/dist/images/taqwright_logo.png +0 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/inspector/codegen-appium.d.ts +3 -0
- package/dist/inspector/codegen-appium.js +228 -0
- package/dist/inspector/devices.d.ts +41 -0
- package/dist/inspector/devices.js +422 -0
- package/dist/inspector/locator-suggester.d.ts +23 -0
- package/dist/inspector/locator-suggester.js +539 -0
- package/dist/inspector/recorder.d.ts +128 -0
- package/dist/inspector/recorder.js +162 -0
- package/dist/inspector/server.d.ts +39 -0
- package/dist/inspector/server.js +1210 -0
- package/dist/inspector/session.d.ts +84 -0
- package/dist/inspector/session.js +262 -0
- package/dist/inspector/ui.d.ts +1 -0
- package/dist/inspector/ui.js +5508 -0
- package/dist/keys.d.ts +3 -0
- package/dist/keys.js +28 -0
- package/dist/locator/index.d.ts +206 -0
- package/dist/locator/index.js +1506 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.js +5 -0
- package/dist/mobile/index.d.ts +130 -0
- package/dist/mobile/index.js +762 -0
- package/dist/network/android.d.ts +5 -0
- package/dist/network/android.js +87 -0
- package/dist/network/ca.d.ts +10 -0
- package/dist/network/ca.js +136 -0
- package/dist/network/har.d.ts +90 -0
- package/dist/network/har.js +101 -0
- package/dist/network/host-proxy.d.ts +16 -0
- package/dist/network/host-proxy.js +134 -0
- package/dist/network/index.d.ts +26 -0
- package/dist/network/index.js +105 -0
- package/dist/network/ios-sim.d.ts +3 -0
- package/dist/network/ios-sim.js +29 -0
- package/dist/network/proxy.d.ts +13 -0
- package/dist/network/proxy.js +310 -0
- package/dist/providers/appium.d.ts +23 -0
- package/dist/providers/appium.js +288 -0
- package/dist/providers/browserstack/index.d.ts +5 -0
- package/dist/providers/browserstack/index.js +77 -0
- package/dist/providers/browserstack/utils.d.ts +1 -0
- package/dist/providers/browserstack/utils.js +6 -0
- package/dist/providers/cloud.d.ts +53 -0
- package/dist/providers/cloud.js +117 -0
- package/dist/providers/emulator/index.d.ts +8 -0
- package/dist/providers/emulator/index.js +47 -0
- package/dist/providers/index.d.ts +10 -0
- package/dist/providers/index.js +33 -0
- package/dist/providers/lambdatest/index.d.ts +28 -0
- package/dist/providers/lambdatest/index.js +99 -0
- package/dist/providers/lambdatest/utils.d.ts +1 -0
- package/dist/providers/lambdatest/utils.js +6 -0
- package/dist/providers/local/index.d.ts +9 -0
- package/dist/providers/local/index.js +53 -0
- package/dist/providers/local-session.d.ts +16 -0
- package/dist/providers/local-session.js +55 -0
- package/dist/setup/archive.d.ts +2 -0
- package/dist/setup/archive.js +43 -0
- package/dist/setup/avd.d.ts +12 -0
- package/dist/setup/avd.js +103 -0
- package/dist/setup/index.d.ts +6 -0
- package/dist/setup/index.js +55 -0
- package/dist/setup/install-android.d.ts +2 -0
- package/dist/setup/install-android.js +70 -0
- package/dist/setup/install-appium.d.ts +1 -0
- package/dist/setup/install-appium.js +64 -0
- package/dist/setup/install-jdk.d.ts +1 -0
- package/dist/setup/install-jdk.js +58 -0
- package/dist/setup/paths.d.ts +16 -0
- package/dist/setup/paths.js +88 -0
- package/dist/setup/spawn-tool.d.ts +3 -0
- package/dist/setup/spawn-tool.js +11 -0
- package/dist/tracer/index.d.ts +34 -0
- package/dist/tracer/index.js +687 -0
- package/dist/tracer/proxy.d.ts +3 -0
- package/dist/tracer/proxy.js +60 -0
- package/dist/types/index.d.ts +189 -0
- package/dist/types/index.js +6 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +37 -0
- package/package.json +79 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { Locator, } from '../locator/index.js';
|
|
3
|
+
import { ANDROID_NAMED_KEYS, IOS_NAMED_KEYS } from '../keys.js';
|
|
4
|
+
import { Platform, } from '../types/index.js';
|
|
5
|
+
export class Mobile {
|
|
6
|
+
driver;
|
|
7
|
+
platform;
|
|
8
|
+
defaultBundleId;
|
|
9
|
+
ctx;
|
|
10
|
+
constructor(driver, platform, defaultBundleId, defaultTimeout) {
|
|
11
|
+
this.driver = driver;
|
|
12
|
+
this.platform = platform;
|
|
13
|
+
this.defaultBundleId = defaultBundleId;
|
|
14
|
+
this.ctx = { driver, platform, defaultTimeout };
|
|
15
|
+
}
|
|
16
|
+
static wrap(driver, platform, bundleId, timeout) {
|
|
17
|
+
return new Mobile(driver, platform, bundleId, timeout);
|
|
18
|
+
}
|
|
19
|
+
get raw() {
|
|
20
|
+
return this.driver;
|
|
21
|
+
}
|
|
22
|
+
getPlatform() {
|
|
23
|
+
return this.platform;
|
|
24
|
+
}
|
|
25
|
+
getByText(text, opts) {
|
|
26
|
+
if (typeof text === 'string') {
|
|
27
|
+
const exact = opts?.exact ?? true;
|
|
28
|
+
if (this.platform === Platform.IOS) {
|
|
29
|
+
const escaped = escapeSingleQuotes(text);
|
|
30
|
+
const value = exact
|
|
31
|
+
? `label == '${escaped}' OR value == '${escaped}' OR name == '${escaped}'`
|
|
32
|
+
: `label CONTAINS '${escaped}' OR value CONTAINS '${escaped}'`;
|
|
33
|
+
return Locator.fromStrategy(this.ctx, { using: '-ios predicate string', value });
|
|
34
|
+
}
|
|
35
|
+
const literal = xpathLiteral(text);
|
|
36
|
+
const xpath = exact ? `//*[@text=${literal}]` : `//*[contains(@text, ${literal})]`;
|
|
37
|
+
return Locator.fromStrategy(this.ctx, { using: 'xpath', value: xpath });
|
|
38
|
+
}
|
|
39
|
+
const broad = this.platform === Platform.IOS
|
|
40
|
+
? { using: 'xpath', value: '//*[@label or @value]', textFilter: text }
|
|
41
|
+
: { using: 'xpath', value: '//*[@text]', textFilter: text };
|
|
42
|
+
return Locator.fromStrategy(this.ctx, broad);
|
|
43
|
+
}
|
|
44
|
+
getByLabel(label, _opts) {
|
|
45
|
+
return Locator.fromStrategy(this.ctx, { using: 'accessibility id', value: label });
|
|
46
|
+
}
|
|
47
|
+
getById(id) {
|
|
48
|
+
if (this.platform === Platform.IOS) {
|
|
49
|
+
return Locator.fromStrategy(this.ctx, { using: 'accessibility id', value: id });
|
|
50
|
+
}
|
|
51
|
+
return Locator.fromStrategy(this.ctx, { using: 'id', value: id });
|
|
52
|
+
}
|
|
53
|
+
getByTestId(id) {
|
|
54
|
+
return this.getById(id);
|
|
55
|
+
}
|
|
56
|
+
getByPlaceholder(text, opts) {
|
|
57
|
+
const exact = opts?.exact ?? true;
|
|
58
|
+
if (this.platform === Platform.IOS) {
|
|
59
|
+
const escaped = escapeSingleQuotes(text);
|
|
60
|
+
const value = exact
|
|
61
|
+
? `placeholderValue == '${escaped}'`
|
|
62
|
+
: `placeholderValue CONTAINS '${escaped}'`;
|
|
63
|
+
return Locator.fromStrategy(this.ctx, { using: '-ios predicate string', value });
|
|
64
|
+
}
|
|
65
|
+
const literal = xpathLiteral(text);
|
|
66
|
+
const xpath = exact ? `//*[@hint=${literal}]` : `//*[contains(@hint, ${literal})]`;
|
|
67
|
+
return Locator.fromStrategy(this.ctx, { using: 'xpath', value: xpath });
|
|
68
|
+
}
|
|
69
|
+
getByRole(role, opts) {
|
|
70
|
+
const typeMap = {
|
|
71
|
+
button: { android: 'android.widget.Button', ios: 'XCUIElementTypeButton' },
|
|
72
|
+
link: { android: 'android.widget.TextView', ios: 'XCUIElementTypeLink' },
|
|
73
|
+
textbox: { android: 'android.widget.EditText', ios: 'XCUIElementTypeTextField' },
|
|
74
|
+
switch: { android: 'android.widget.Switch', ios: 'XCUIElementTypeSwitch' },
|
|
75
|
+
image: { android: 'android.widget.ImageView', ios: 'XCUIElementTypeImage' },
|
|
76
|
+
};
|
|
77
|
+
const cls = typeMap[role.toLowerCase()];
|
|
78
|
+
const using = 'class name';
|
|
79
|
+
const value = cls ? (this.platform === Platform.IOS ? cls.ios : cls.android) : role;
|
|
80
|
+
return Locator.fromStrategy(this.ctx, { using, value, textFilter: opts?.name });
|
|
81
|
+
}
|
|
82
|
+
getByType(type) {
|
|
83
|
+
return Locator.fromStrategy(this.ctx, { using: 'class name', value: type });
|
|
84
|
+
}
|
|
85
|
+
getByXpath(xpath) {
|
|
86
|
+
const trimmed = xpath.trim();
|
|
87
|
+
const m = trimmed.match(/^\/\/([\w.]+)?(?:\[@([\w-]+)\s*=\s*"([^"]*)"\])?$/);
|
|
88
|
+
if (m) {
|
|
89
|
+
const [, type, attr, value] = m;
|
|
90
|
+
if (attr && value !== undefined) {
|
|
91
|
+
switch (attr) {
|
|
92
|
+
case 'hint':
|
|
93
|
+
case 'placeholderValue':
|
|
94
|
+
return this.getByPlaceholder(value);
|
|
95
|
+
case 'content-desc':
|
|
96
|
+
case 'contentDescription':
|
|
97
|
+
case 'accessibilityLabel':
|
|
98
|
+
case 'name':
|
|
99
|
+
case 'label':
|
|
100
|
+
return this.getByLabel(value);
|
|
101
|
+
case 'text':
|
|
102
|
+
return this.getByText(value);
|
|
103
|
+
case 'resource-id': {
|
|
104
|
+
const id = value.includes(':id/') ? value.split(':id/')[1] : value;
|
|
105
|
+
return this.getById(id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (type && !attr) {
|
|
110
|
+
return this.getByType(type);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return Locator.fromStrategy(this.ctx, { using: 'xpath', value: xpath });
|
|
114
|
+
}
|
|
115
|
+
getByCss(selector) {
|
|
116
|
+
return Locator.fromStrategy(this.ctx, { using: 'css selector', value: selector });
|
|
117
|
+
}
|
|
118
|
+
getByUiSelector(selector) {
|
|
119
|
+
if (this.platform !== Platform.ANDROID) {
|
|
120
|
+
throw new Error('getByUiSelector is Android-only; use getByPredicate or getByClassChain on iOS');
|
|
121
|
+
}
|
|
122
|
+
return Locator.fromStrategy(this.ctx, { using: '-android uiautomator', value: selector });
|
|
123
|
+
}
|
|
124
|
+
getByPredicate(predicate) {
|
|
125
|
+
if (this.platform !== Platform.IOS) {
|
|
126
|
+
throw new Error('getByPredicate is iOS-only; use getByUiSelector on Android');
|
|
127
|
+
}
|
|
128
|
+
return Locator.fromStrategy(this.ctx, { using: '-ios predicate string', value: predicate });
|
|
129
|
+
}
|
|
130
|
+
getByClassChain(chain) {
|
|
131
|
+
if (this.platform !== Platform.IOS) {
|
|
132
|
+
throw new Error('getByClassChain is iOS-only; use getByUiSelector on Android');
|
|
133
|
+
}
|
|
134
|
+
return Locator.fromStrategy(this.ctx, { using: '-ios class chain', value: chain });
|
|
135
|
+
}
|
|
136
|
+
async installApp(path) {
|
|
137
|
+
await this.driver.executeScript('mobile: installApp', [{ app: path }]);
|
|
138
|
+
}
|
|
139
|
+
async uninstallApp(bundleId) {
|
|
140
|
+
const id = bundleId ?? this.defaultBundleId;
|
|
141
|
+
if (!id)
|
|
142
|
+
throw new Error('uninstallApp: bundleId not provided and no default configured');
|
|
143
|
+
await this.driver.executeScript('mobile: removeApp', [{ bundleId: id, appId: id }]);
|
|
144
|
+
}
|
|
145
|
+
async launchApp(bundleId, opts) {
|
|
146
|
+
const id = bundleId ?? this.defaultBundleId;
|
|
147
|
+
if (!id)
|
|
148
|
+
throw new Error('launchApp: bundleId not provided and no default configured');
|
|
149
|
+
const arg = this.platform === Platform.IOS ? { bundleId: id } : { appId: id };
|
|
150
|
+
await this.driver.executeScript('mobile: activateApp', [arg]);
|
|
151
|
+
if (!opts?.noWaitAfter) {
|
|
152
|
+
await this.waitForAppForeground(arg);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async waitForAppForeground(arg) {
|
|
156
|
+
const deadline = Date.now() + 3000;
|
|
157
|
+
while (Date.now() < deadline) {
|
|
158
|
+
const state = await this.driver
|
|
159
|
+
.executeScript('mobile: queryAppState', [arg])
|
|
160
|
+
.catch(() => undefined);
|
|
161
|
+
if (typeof state === 'number' && state >= 3)
|
|
162
|
+
return;
|
|
163
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async terminateApp(bundleId) {
|
|
167
|
+
const id = bundleId ?? this.defaultBundleId;
|
|
168
|
+
if (!id)
|
|
169
|
+
throw new Error('terminateApp: bundleId not provided and no default configured');
|
|
170
|
+
const arg = this.platform === Platform.IOS ? { bundleId: id } : { appId: id };
|
|
171
|
+
await this.driver.executeScript('mobile: terminateApp', [arg]);
|
|
172
|
+
}
|
|
173
|
+
async activateApp(bundleId) {
|
|
174
|
+
return this.launchApp(bundleId);
|
|
175
|
+
}
|
|
176
|
+
async close() {
|
|
177
|
+
try {
|
|
178
|
+
await this.driver.deleteSession();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async click(point) {
|
|
184
|
+
try {
|
|
185
|
+
await this.driver.performActions([
|
|
186
|
+
{
|
|
187
|
+
type: 'pointer',
|
|
188
|
+
id: 'finger1',
|
|
189
|
+
parameters: { pointerType: 'touch' },
|
|
190
|
+
actions: [
|
|
191
|
+
{ type: 'pointerMove', duration: 0, x: point.x, y: point.y },
|
|
192
|
+
{ type: 'pointerDown', button: 0 },
|
|
193
|
+
{ type: 'pointerUp', button: 0 },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await this.driver.releaseActions().catch(() => { });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async tap(point) {
|
|
203
|
+
return this.click(point);
|
|
204
|
+
}
|
|
205
|
+
async swipe(direction, opts) {
|
|
206
|
+
const rect = await this.driver.getWindowRect();
|
|
207
|
+
const cx = Math.floor(rect.width / 2);
|
|
208
|
+
const cy = Math.floor(rect.height / 2);
|
|
209
|
+
const span = Math.floor(Math.min(rect.width, rect.height) * (opts?.distance ?? 0.4));
|
|
210
|
+
const defaultFromX = direction === 'left' ? cx + span : direction === 'right' ? cx - span : cx;
|
|
211
|
+
const defaultFromY = direction === 'up' ? cy + span : direction === 'down' ? cy - span : cy;
|
|
212
|
+
const defaultToX = direction === 'left' ? cx - span : direction === 'right' ? cx + span : cx;
|
|
213
|
+
const defaultToY = direction === 'up' ? cy - span : direction === 'down' ? cy + span : cy;
|
|
214
|
+
const fromX = opts?.from?.x !== undefined ? Math.floor(rect.width * opts.from.x) : defaultFromX;
|
|
215
|
+
const fromY = opts?.from?.y !== undefined ? Math.floor(rect.height * opts.from.y) : defaultFromY;
|
|
216
|
+
const toX = opts?.to?.x !== undefined ? Math.floor(rect.width * opts.to.x) : defaultToX;
|
|
217
|
+
const toY = opts?.to?.y !== undefined ? Math.floor(rect.height * opts.to.y) : defaultToY;
|
|
218
|
+
if (this.platform === Platform.ANDROID) {
|
|
219
|
+
const yFrac = opts?.from?.y !== undefined || opts?.to?.y !== undefined
|
|
220
|
+
? [opts?.from?.y ?? opts?.to?.y ?? 0.4, opts?.to?.y ?? opts?.from?.y ?? 0.6]
|
|
221
|
+
: [0.4, 0.6];
|
|
222
|
+
const xFrac = opts?.from?.x !== undefined || opts?.to?.x !== undefined
|
|
223
|
+
? [opts?.from?.x ?? opts?.to?.x ?? 0.5, opts?.to?.x ?? opts?.from?.x ?? 0.5]
|
|
224
|
+
: [0.5, 0.5];
|
|
225
|
+
const yLow = Math.min(yFrac[0], yFrac[1]);
|
|
226
|
+
const yHigh = Math.max(yFrac[0], yFrac[1]);
|
|
227
|
+
const xLow = Math.min(xFrac[0], xFrac[1]);
|
|
228
|
+
const xHigh = Math.max(xFrac[0], xFrac[1]);
|
|
229
|
+
try {
|
|
230
|
+
await this.driver.executeScript('mobile: swipeGesture', [
|
|
231
|
+
{
|
|
232
|
+
left: Math.floor(rect.width * xLow),
|
|
233
|
+
top: Math.floor(rect.height * yLow),
|
|
234
|
+
width: Math.max(2, Math.floor(rect.width * (xHigh - xLow))),
|
|
235
|
+
height: Math.max(2, Math.floor(rect.height * (yHigh - yLow))),
|
|
236
|
+
direction,
|
|
237
|
+
percent: opts?.distance ?? 0.75,
|
|
238
|
+
},
|
|
239
|
+
]);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (this.platform === Platform.IOS) {
|
|
246
|
+
try {
|
|
247
|
+
await this.driver.executeScript('mobile: swipe', [{ direction }]);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
await this.driver.performActions([
|
|
255
|
+
{
|
|
256
|
+
type: 'pointer',
|
|
257
|
+
id: 'finger1',
|
|
258
|
+
parameters: { pointerType: 'touch' },
|
|
259
|
+
actions: [
|
|
260
|
+
{ type: 'pointerMove', duration: 0, x: fromX, y: fromY },
|
|
261
|
+
{ type: 'pointerDown', button: 0 },
|
|
262
|
+
{ type: 'pointerMove', duration: opts?.duration ?? 300, x: toX, y: toY },
|
|
263
|
+
{ type: 'pointerUp', button: 0 },
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
await this.driver.releaseActions().catch(() => { });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async scroll(direction = 'down', opts) {
|
|
273
|
+
const fingerDir = {
|
|
274
|
+
up: 'down',
|
|
275
|
+
down: 'up',
|
|
276
|
+
left: 'right',
|
|
277
|
+
right: 'left',
|
|
278
|
+
};
|
|
279
|
+
return this.swipe(fingerDir[direction], opts);
|
|
280
|
+
}
|
|
281
|
+
async scrollIntoView(locator, opts) {
|
|
282
|
+
return locator.scrollIntoView(opts);
|
|
283
|
+
}
|
|
284
|
+
async dragAndDrop(from, to, opts) {
|
|
285
|
+
const holdMs = opts?.duration ?? 500;
|
|
286
|
+
if (this.platform === Platform.IOS) {
|
|
287
|
+
try {
|
|
288
|
+
await this.driver.executeScript('mobile: dragFromToForDuration', [
|
|
289
|
+
{
|
|
290
|
+
duration: holdMs / 1000,
|
|
291
|
+
fromX: from.x,
|
|
292
|
+
fromY: from.y,
|
|
293
|
+
toX: to.x,
|
|
294
|
+
toY: to.y,
|
|
295
|
+
},
|
|
296
|
+
]);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (this.platform === Platform.ANDROID) {
|
|
303
|
+
try {
|
|
304
|
+
await this.driver.executeScript('mobile: dragGesture', [
|
|
305
|
+
{
|
|
306
|
+
startX: from.x,
|
|
307
|
+
startY: from.y,
|
|
308
|
+
endX: to.x,
|
|
309
|
+
endY: to.y,
|
|
310
|
+
speed: opts?.speed ?? 2500,
|
|
311
|
+
},
|
|
312
|
+
]);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const moveMs = opts?.moveDuration ?? 300;
|
|
319
|
+
try {
|
|
320
|
+
await this.driver.performActions([
|
|
321
|
+
{
|
|
322
|
+
type: 'pointer',
|
|
323
|
+
id: 'finger1',
|
|
324
|
+
parameters: { pointerType: 'touch' },
|
|
325
|
+
actions: [
|
|
326
|
+
{ type: 'pointerMove', duration: 0, x: from.x, y: from.y },
|
|
327
|
+
{ type: 'pointerDown', button: 0 },
|
|
328
|
+
{ type: 'pause', duration: holdMs },
|
|
329
|
+
{ type: 'pointerMove', duration: moveMs, x: to.x, y: to.y },
|
|
330
|
+
{ type: 'pointerUp', button: 0 },
|
|
331
|
+
],
|
|
332
|
+
},
|
|
333
|
+
]);
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
await this.driver.releaseActions().catch(() => { });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async press(key) {
|
|
340
|
+
if (this.platform === Platform.ANDROID) {
|
|
341
|
+
const code = ANDROID_NAMED_KEYS[key];
|
|
342
|
+
if (code === undefined) {
|
|
343
|
+
const fallback = {
|
|
344
|
+
Enter: 66,
|
|
345
|
+
Tab: 61,
|
|
346
|
+
Backspace: 67,
|
|
347
|
+
Space: 62,
|
|
348
|
+
};
|
|
349
|
+
const fk = fallback[key];
|
|
350
|
+
if (fk === undefined) {
|
|
351
|
+
throw new Error(`mobile.press: unsupported Android key "${key}"`);
|
|
352
|
+
}
|
|
353
|
+
await this.driver.executeScript('mobile: pressKey', [{ keycode: fk }]);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
await this.driver.executeScript('mobile: pressKey', [{ keycode: code }]);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const name = IOS_NAMED_KEYS[key];
|
|
360
|
+
if (name === undefined) {
|
|
361
|
+
throw new Error(`mobile.press: unsupported iOS key "${key}"`);
|
|
362
|
+
}
|
|
363
|
+
await this.driver.executeScript('mobile: keys', [{ keys: [{ name }] }]);
|
|
364
|
+
}
|
|
365
|
+
async pressButton(button) {
|
|
366
|
+
if (this.platform === Platform.ANDROID) {
|
|
367
|
+
const keycode = ANDROID_KEYCODES[button];
|
|
368
|
+
if (keycode === undefined) {
|
|
369
|
+
throw new Error(`pressButton: unknown Android button "${button}"`);
|
|
370
|
+
}
|
|
371
|
+
await this.driver.executeScript('mobile: pressKey', [{ keycode }]);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (button === 'HOME') {
|
|
375
|
+
await this.driver.executeScript('mobile: pressButton', [{ name: 'home' }]);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (button === 'VOLUME_UP' || button === 'VOLUME_DOWN') {
|
|
379
|
+
await this.driver.executeScript('mobile: pressButton', [
|
|
380
|
+
{ name: button === 'VOLUME_UP' ? 'volumeup' : 'volumedown' },
|
|
381
|
+
]);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
throw new Error(`pressButton: "${button}" is not supported on iOS`);
|
|
385
|
+
}
|
|
386
|
+
async goBack() {
|
|
387
|
+
if (this.platform === Platform.ANDROID) {
|
|
388
|
+
await this.driver.back();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const rect = await this.driver.getWindowRect();
|
|
392
|
+
try {
|
|
393
|
+
await this.driver.performActions([
|
|
394
|
+
{
|
|
395
|
+
type: 'pointer',
|
|
396
|
+
id: 'finger1',
|
|
397
|
+
parameters: { pointerType: 'touch' },
|
|
398
|
+
actions: [
|
|
399
|
+
{ type: 'pointerMove', duration: 0, x: 1, y: Math.floor(rect.height / 2) },
|
|
400
|
+
{ type: 'pointerDown', button: 0 },
|
|
401
|
+
{
|
|
402
|
+
type: 'pointerMove',
|
|
403
|
+
duration: 200,
|
|
404
|
+
x: Math.floor(rect.width / 2),
|
|
405
|
+
y: Math.floor(rect.height / 2),
|
|
406
|
+
},
|
|
407
|
+
{ type: 'pointerUp', button: 0 },
|
|
408
|
+
],
|
|
409
|
+
},
|
|
410
|
+
]);
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
await this.driver.releaseActions().catch(() => { });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async screenshot() {
|
|
417
|
+
const data = await this.driver.takeScreenshot();
|
|
418
|
+
return Buffer.from(data, 'base64');
|
|
419
|
+
}
|
|
420
|
+
async getScreenSize() {
|
|
421
|
+
const rect = await this.driver.getWindowRect();
|
|
422
|
+
return { width: rect.width, height: rect.height };
|
|
423
|
+
}
|
|
424
|
+
async gesture(opts) {
|
|
425
|
+
const actions = opts.pointers.map((pointerPath, i) => {
|
|
426
|
+
const inner = [];
|
|
427
|
+
const first = pointerPath[0];
|
|
428
|
+
if (first) {
|
|
429
|
+
inner.push({ type: 'pointerMove', duration: 0, x: first.x, y: first.y });
|
|
430
|
+
inner.push({ type: 'pointerDown', button: 0 });
|
|
431
|
+
let prevTime = first.time;
|
|
432
|
+
for (let j = 1; j < pointerPath.length; j++) {
|
|
433
|
+
const point = pointerPath[j];
|
|
434
|
+
inner.push({
|
|
435
|
+
type: 'pointerMove',
|
|
436
|
+
duration: Math.max(0, point.time - prevTime),
|
|
437
|
+
x: point.x,
|
|
438
|
+
y: point.y,
|
|
439
|
+
});
|
|
440
|
+
prevTime = point.time;
|
|
441
|
+
}
|
|
442
|
+
inner.push({ type: 'pointerUp', button: 0 });
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
type: 'pointer',
|
|
446
|
+
id: `finger${i + 1}`,
|
|
447
|
+
parameters: { pointerType: 'touch' },
|
|
448
|
+
actions: inner,
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
try {
|
|
452
|
+
await this.driver.performActions(actions);
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
await this.driver.releaseActions().catch(() => { });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async viewTree() {
|
|
459
|
+
return this.driver.getPageSource();
|
|
460
|
+
}
|
|
461
|
+
async getContexts() {
|
|
462
|
+
return (await this.driver.getAppiumContexts());
|
|
463
|
+
}
|
|
464
|
+
async getContext() {
|
|
465
|
+
return (await this.driver.getAppiumContext());
|
|
466
|
+
}
|
|
467
|
+
async switchContext(name) {
|
|
468
|
+
await this.driver.switchAppiumContext(name);
|
|
469
|
+
}
|
|
470
|
+
async switchToWebView(name) {
|
|
471
|
+
if (name) {
|
|
472
|
+
await this.driver.switchAppiumContext(name);
|
|
473
|
+
return name;
|
|
474
|
+
}
|
|
475
|
+
const ctxs = (await this.driver.getAppiumContexts());
|
|
476
|
+
const web = ctxs.find((c) => /^WEBVIEW/i.test(c));
|
|
477
|
+
if (!web) {
|
|
478
|
+
throw new Error(`No WebView context available (found: ${ctxs.join(', ') || 'none'}). ` +
|
|
479
|
+
`On Android a WebView needs chromedriver — enable ` +
|
|
480
|
+
`appium:chromedriverAutodownload or set appium:chromedriverExecutable.`);
|
|
481
|
+
}
|
|
482
|
+
await this.driver.switchAppiumContext(web);
|
|
483
|
+
return web;
|
|
484
|
+
}
|
|
485
|
+
async switchToNative() {
|
|
486
|
+
await this.driver.switchAppiumContext('NATIVE_APP');
|
|
487
|
+
}
|
|
488
|
+
async waitForTimeout(ms) {
|
|
489
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
490
|
+
}
|
|
491
|
+
async pause(opts) {
|
|
492
|
+
if (process.env.PWDEBUG === '0')
|
|
493
|
+
return;
|
|
494
|
+
const { startInspectorServer } = await import('../inspector/server.js');
|
|
495
|
+
const handle = await startInspectorServer({
|
|
496
|
+
defaults: { appium: { host: 'localhost', port: 4723, path: '/' }, capabilities: {} },
|
|
497
|
+
host: 'localhost',
|
|
498
|
+
port: opts?.port ?? 0,
|
|
499
|
+
attach: {
|
|
500
|
+
driver: this.driver,
|
|
501
|
+
platform: this.platform,
|
|
502
|
+
...(this.defaultBundleId
|
|
503
|
+
? { capabilities: { 'appium:bundleId': this.defaultBundleId } }
|
|
504
|
+
: {}),
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
process.stdout.write(`\n taqwright paused. Inspector: ${handle.url}\n` +
|
|
508
|
+
` Click "Resume" in the inspector to continue.\n\n`);
|
|
509
|
+
if (opts?.openBrowser !== false) {
|
|
510
|
+
openInBrowser(handle.url);
|
|
511
|
+
}
|
|
512
|
+
const onSigint = () => {
|
|
513
|
+
handle.close().catch(() => { });
|
|
514
|
+
process.exit(130);
|
|
515
|
+
};
|
|
516
|
+
process.once('SIGINT', onSigint);
|
|
517
|
+
try {
|
|
518
|
+
await handle.session.resumeRequested;
|
|
519
|
+
}
|
|
520
|
+
finally {
|
|
521
|
+
process.removeListener('SIGINT', onSigint);
|
|
522
|
+
await handle.close().catch(() => { });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async clickByPercent(box, relX = 0.5, relY = 0.5) {
|
|
526
|
+
return this.click({
|
|
527
|
+
x: Math.floor(box.x + box.width * relX),
|
|
528
|
+
y: Math.floor(box.y + box.height * relY),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
async setOrientation(orientation) {
|
|
532
|
+
await this.driver.setOrientation(orientation.toUpperCase());
|
|
533
|
+
}
|
|
534
|
+
async getOrientation() {
|
|
535
|
+
const o = await this.driver.getOrientation();
|
|
536
|
+
return o.toLowerCase() === 'landscape' ? 'landscape' : 'portrait';
|
|
537
|
+
}
|
|
538
|
+
async hideKeyboard() {
|
|
539
|
+
if (this.platform === Platform.IOS) {
|
|
540
|
+
try {
|
|
541
|
+
await this.driver.executeScript('mobile: hideKeyboard', [{}]);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
await this.driver.hideKeyboard();
|
|
548
|
+
}
|
|
549
|
+
async isKeyboardShown() {
|
|
550
|
+
return this.driver.isKeyboardShown();
|
|
551
|
+
}
|
|
552
|
+
async acceptAlert() {
|
|
553
|
+
if (this.platform === Platform.IOS) {
|
|
554
|
+
await this.driver.executeScript('mobile: alert', [{ action: 'accept' }]);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
await this.driver.acceptAlert();
|
|
558
|
+
}
|
|
559
|
+
async dismissAlert() {
|
|
560
|
+
if (this.platform === Platform.IOS) {
|
|
561
|
+
await this.driver.executeScript('mobile: alert', [{ action: 'dismiss' }]);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
await this.driver.dismissAlert();
|
|
565
|
+
}
|
|
566
|
+
async getAlertText() {
|
|
567
|
+
return this.driver.getAlertText();
|
|
568
|
+
}
|
|
569
|
+
async backgroundApp(seconds = -1) {
|
|
570
|
+
await this.driver.executeScript('mobile: backgroundApp', [{ seconds }]);
|
|
571
|
+
}
|
|
572
|
+
async getCurrentApp() {
|
|
573
|
+
if (this.platform === Platform.IOS) {
|
|
574
|
+
const info = (await this.driver.executeScript('mobile: activeAppInfo', [{}]));
|
|
575
|
+
return { bundleId: info?.bundleId ?? '' };
|
|
576
|
+
}
|
|
577
|
+
const pkg = await this.driver.executeScript('mobile: getCurrentPackage', [{}]);
|
|
578
|
+
return { bundleId: typeof pkg === 'string' ? pkg : '' };
|
|
579
|
+
}
|
|
580
|
+
async isAppInstalled(bundleId) {
|
|
581
|
+
const id = bundleId ?? this.defaultBundleId;
|
|
582
|
+
if (!id) {
|
|
583
|
+
throw new Error('isAppInstalled: bundleId not provided and no default configured');
|
|
584
|
+
}
|
|
585
|
+
const result = await this.driver.executeScript('mobile: isAppInstalled', [this.appArg(id)]);
|
|
586
|
+
return result === true;
|
|
587
|
+
}
|
|
588
|
+
async queryAppState(bundleId) {
|
|
589
|
+
const id = bundleId ?? this.defaultBundleId;
|
|
590
|
+
if (!id) {
|
|
591
|
+
throw new Error('queryAppState: bundleId not provided and no default configured');
|
|
592
|
+
}
|
|
593
|
+
const state = await this.driver.executeScript('mobile: queryAppState', [this.appArg(id)]);
|
|
594
|
+
if (state === 0)
|
|
595
|
+
return 'not_installed';
|
|
596
|
+
if (state === 1)
|
|
597
|
+
return 'not_running';
|
|
598
|
+
if (state === 2 || state === 3)
|
|
599
|
+
return 'background';
|
|
600
|
+
if (state === 4)
|
|
601
|
+
return 'foreground';
|
|
602
|
+
return 'not_running';
|
|
603
|
+
}
|
|
604
|
+
async openDeepLink(url, bundleId) {
|
|
605
|
+
const id = bundleId ?? this.defaultBundleId;
|
|
606
|
+
if (!id) {
|
|
607
|
+
throw new Error('openDeepLink: bundleId not provided and no default configured');
|
|
608
|
+
}
|
|
609
|
+
const arg = this.platform === Platform.IOS ? { url, bundleId: id } : { url, package: id };
|
|
610
|
+
await this.driver.executeScript('mobile: deepLink', [arg]);
|
|
611
|
+
}
|
|
612
|
+
async getClipboard() {
|
|
613
|
+
const cmd = this.platform === Platform.IOS ? 'mobile: getPasteboard' : 'mobile: getClipboard';
|
|
614
|
+
const data = await this.driver.executeScript(cmd, [{}]);
|
|
615
|
+
const b64 = typeof data === 'string' ? data : '';
|
|
616
|
+
return Buffer.from(b64, 'base64').toString('utf-8');
|
|
617
|
+
}
|
|
618
|
+
async setClipboard(text) {
|
|
619
|
+
const cmd = this.platform === Platform.IOS ? 'mobile: setPasteboard' : 'mobile: setClipboard';
|
|
620
|
+
await this.driver.executeScript(cmd, [
|
|
621
|
+
{ content: Buffer.from(text, 'utf-8').toString('base64') },
|
|
622
|
+
]);
|
|
623
|
+
}
|
|
624
|
+
async setLocation(loc) {
|
|
625
|
+
await this.driver.setGeoLocation({
|
|
626
|
+
latitude: loc.latitude,
|
|
627
|
+
longitude: loc.longitude,
|
|
628
|
+
altitude: loc.altitude ?? 0,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
async getLocation() {
|
|
632
|
+
const result = (await this.driver.getGeoLocation());
|
|
633
|
+
return {
|
|
634
|
+
latitude: Number(result.latitude),
|
|
635
|
+
longitude: Number(result.longitude),
|
|
636
|
+
altitude: Number(result.altitude),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
async setPermission(permission, state) {
|
|
640
|
+
if (this.platform !== Platform.ANDROID) {
|
|
641
|
+
throw new Error('setPermission is Android-only; on iOS, configure permissions via the appium:processArguments / appium:autoAcceptAlerts capability or pre-grant in test setup');
|
|
642
|
+
}
|
|
643
|
+
await this.driver.executeScript('mobile: changePermissions', [
|
|
644
|
+
{ action: state, permissions: [permission] },
|
|
645
|
+
]);
|
|
646
|
+
}
|
|
647
|
+
async setNetworkConnection(opts) {
|
|
648
|
+
if (this.platform !== Platform.ANDROID) {
|
|
649
|
+
throw new Error('setNetworkConnection is Android-only; on iOS, configure via macOS Network Link Conditioner');
|
|
650
|
+
}
|
|
651
|
+
await this.driver.executeScript('mobile: setConnectivity', [
|
|
652
|
+
{
|
|
653
|
+
...(opts.wifi !== undefined ? { wifi: opts.wifi } : {}),
|
|
654
|
+
...(opts.data !== undefined ? { data: opts.data } : {}),
|
|
655
|
+
...(opts.airplane !== undefined ? { airplaneMode: opts.airplane } : {}),
|
|
656
|
+
},
|
|
657
|
+
]);
|
|
658
|
+
}
|
|
659
|
+
async getNetworkConnection() {
|
|
660
|
+
if (this.platform !== Platform.ANDROID) {
|
|
661
|
+
throw new Error('getNetworkConnection is Android-only');
|
|
662
|
+
}
|
|
663
|
+
const result = (await this.driver.executeScript('mobile: getConnectivity', [{}]));
|
|
664
|
+
return {
|
|
665
|
+
wifi: !!result?.wifi,
|
|
666
|
+
data: !!result?.data,
|
|
667
|
+
airplane: !!result?.airplaneMode,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
async pushFile(remotePath, content) {
|
|
671
|
+
const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
|
|
672
|
+
await this.driver.executeScript('mobile: pushFile', [
|
|
673
|
+
{ remotePath, payload: buf.toString('base64') },
|
|
674
|
+
]);
|
|
675
|
+
}
|
|
676
|
+
async pullFile(remotePath) {
|
|
677
|
+
const data = await this.driver.executeScript('mobile: pullFile', [{ remotePath }]);
|
|
678
|
+
return Buffer.from(typeof data === 'string' ? data : '', 'base64');
|
|
679
|
+
}
|
|
680
|
+
async getDeviceLogs(type) {
|
|
681
|
+
const logType = type ?? (this.platform === Platform.ANDROID ? 'logcat' : 'syslog');
|
|
682
|
+
const entries = (await this.driver.getLogs(logType));
|
|
683
|
+
return entries.map((e) => ({
|
|
684
|
+
timestamp: Number(e.timestamp ?? Date.now()),
|
|
685
|
+
level: String(e.level ?? 'INFO'),
|
|
686
|
+
message: String(e.message ?? ''),
|
|
687
|
+
}));
|
|
688
|
+
}
|
|
689
|
+
async getLogTypes() {
|
|
690
|
+
return this.driver.getLogTypes();
|
|
691
|
+
}
|
|
692
|
+
async getDeviceTime(format) {
|
|
693
|
+
const arg = format ? { format } : {};
|
|
694
|
+
const t = await this.driver.executeScript('mobile: getDeviceTime', [arg]);
|
|
695
|
+
return typeof t === 'string' ? t : String(t);
|
|
696
|
+
}
|
|
697
|
+
async setLocale(locale) {
|
|
698
|
+
if (this.platform !== Platform.ANDROID) {
|
|
699
|
+
throw new Error('setLocale is Android-only at runtime; on iOS, set via the appium:language and appium:locale capabilities');
|
|
700
|
+
}
|
|
701
|
+
const [language, country] = locale.includes('-') ? locale.split('-') : [locale, undefined];
|
|
702
|
+
await this.driver.executeScript('mobile: setLocale', [
|
|
703
|
+
{ language, ...(country ? { country } : {}) },
|
|
704
|
+
]);
|
|
705
|
+
}
|
|
706
|
+
async startScreenRecording(opts) {
|
|
707
|
+
const arg = {};
|
|
708
|
+
if (opts?.videoType)
|
|
709
|
+
arg.videoType = opts.videoType;
|
|
710
|
+
if (opts?.timeLimit !== undefined)
|
|
711
|
+
arg.timeLimit = String(opts.timeLimit);
|
|
712
|
+
await this.driver.startRecordingScreen(arg);
|
|
713
|
+
}
|
|
714
|
+
async stopScreenRecording() {
|
|
715
|
+
const data = await this.driver.stopRecordingScreen();
|
|
716
|
+
return Buffer.from(typeof data === 'string' ? data : '', 'base64');
|
|
717
|
+
}
|
|
718
|
+
appArg(id) {
|
|
719
|
+
return this.platform === Platform.IOS ? { bundleId: id } : { appId: id };
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function openInBrowser(url) {
|
|
723
|
+
try {
|
|
724
|
+
const cmd = process.platform === 'darwin'
|
|
725
|
+
? { c: 'open', args: [url] }
|
|
726
|
+
: process.platform === 'win32'
|
|
727
|
+
? { c: 'cmd', args: ['/c', 'start', '""', url] }
|
|
728
|
+
: { c: 'xdg-open', args: [url] };
|
|
729
|
+
const child = spawn(cmd.c, cmd.args, { stdio: 'ignore', detached: true });
|
|
730
|
+
child.on('error', () => {
|
|
731
|
+
});
|
|
732
|
+
child.unref();
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const ANDROID_KEYCODES = {
|
|
738
|
+
HOME: 3,
|
|
739
|
+
BACK: 4,
|
|
740
|
+
VOLUME_UP: 24,
|
|
741
|
+
VOLUME_DOWN: 25,
|
|
742
|
+
POWER: 26,
|
|
743
|
+
ENTER: 66,
|
|
744
|
+
};
|
|
745
|
+
function escapeSingleQuotes(s) {
|
|
746
|
+
return s.replace(/'/g, "\\'");
|
|
747
|
+
}
|
|
748
|
+
function xpathLiteral(s) {
|
|
749
|
+
if (!s.includes("'"))
|
|
750
|
+
return `'${s}'`;
|
|
751
|
+
if (!s.includes('"'))
|
|
752
|
+
return `"${s}"`;
|
|
753
|
+
const parts = s.split("'");
|
|
754
|
+
const pieces = [];
|
|
755
|
+
parts.forEach((part, i) => {
|
|
756
|
+
if (i > 0)
|
|
757
|
+
pieces.push(`"'"`);
|
|
758
|
+
if (part.length > 0)
|
|
759
|
+
pieces.push(`'${part}'`);
|
|
760
|
+
});
|
|
761
|
+
return `concat(${pieces.join(', ')})`;
|
|
762
|
+
}
|